Files
hermes-agent/plugins/platforms/teams/adapter.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

855 lines
48 KiB
Python

"""Microsoft Teams adapter: microsoft-teams-apps SDK for auth/activity processing, an aiohttp
webhook server for inbound, ``App.send()`` for proactive sends.
Requires the ``teams`` extra (auto-installed by the gateway on first start, or
``<hermes-venv>/bin/pip install microsoft-teams-apps aiohttp``) and credentials via env
(TEAMS_CLIENT_ID / TEAMS_CLIENT_SECRET / TEAMS_TENANT_ID, optional TEAMS_PORT) or
``platforms.teams.extra`` in config.yaml (``client_id`` / ``client_secret`` / ``tenant_id`` / ``port``).
"""
from __future__ import annotations
import asyncio
# microsoft-teams-apps calls ``load_dotenv(find_dotenv(usecwd=True))`` at ``microsoft_teams.apps.app``
# import time. Importing it during plugin discovery / ``TeamsSummaryWriter`` imports would pollute process
# ``os.environ`` from a cwd-discovered ``.env`` (#62935). Detect presence via find_spec only; bind symbols
# in ``check_teams_requirements()`` behind a dotenv no-op.
import importlib.util
import json
import logging
import re
import sys
from collections import deque
from contextlib import contextmanager, suppress
from typing import Any, Dict, Iterator, Optional
from urllib.parse import urlparse
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
def _probe_teams_sdk_available() -> bool:
"""True when ``microsoft_teams.apps`` is on sys.path, without importing it: the SDK loads a cwd
``.env`` at import, so ``check_teams_requirements()`` binds symbols behind a dotenv no-op.
Sibling packages share the namespace, so probe the parent first — ``find_spec`` of the child
raises on 3.11+ if the parent is absent."""
try:
find_spec = importlib.util.find_spec
return find_spec("microsoft_teams") is not None and find_spec("microsoft_teams.apps") is not None
except (ValueError, ModuleNotFoundError, ImportError):
return "microsoft_teams.apps" in sys.modules # test stubs may lack ``__spec__``
TEAMS_SDK_AVAILABLE = _probe_teams_sdk_available()
# SDK symbols stay None until check_teams_requirements() binds them (via _SDK_IMPORTS below).
ClientOptions = App = ActivityContext = MessageActivity = ConversationReference = None # type: ignore[assignment,misc]
TypingActivityInput = AdaptiveCardInvokeActivity = AdaptiveCardActionCardResponse = None # type: ignore[assignment,misc]
AdaptiveCardActionMessageResponse = AdaptiveCardInvokeResponse = InvokeResponse = None # type: ignore[assignment,misc]
HttpRequest = HttpResponse = HttpRouteHandler = AdaptiveCard = ExecuteAction = TextBlock = None # type: ignore[assignment,misc]
HttpMethod = str # type: ignore[assignment,misc]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
gateway_trust_env, BasePlatformAdapter, ExecApprovalPrompt, SendResult, cache_image_from_url, cache_media_bytes_async,
)
from gateway.platforms.base_exec_approval import (
EA_HEADER_TEXT, EA_REASON_LABEL_TEXT, approval_timeout_seconds, format_approval_deadline_line)
from gateway.platforms.event import MessageEvent, MessageType
from gateway.platforms._shared import (
coerce_port, extra_or_secret as _extra_or_secret, get_scoped_secret as _get_scoped_secret,
seed_extra_from_env as _seed_extra_from_env, send_error
)
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 3978
_MAX_BODY_BYTES = 1_048_576 # Bot Framework activities are JSON well under 1 MiB
# ``None`` host → aiohttp binds IPv4 + IPv6 ("0.0.0.0" was unreachable on IPv6-only
# networks such as Fly.io 6PN). Pin via TEAMS_HOST or extra.host.
_DEFAULT_HOST = None
_WEBHOOK_PATH = "/api/messages"
# Regional/government tenants override via ``TEAMS_SERVICE_URL`` / ``extra['service_url']``.
_DEFAULT_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/"
# Hosts that may receive a freshly minted bearer token (blocks SSRF / token exfiltration via a
# tampered env var). Exact match only: any Azure customer can register ``<name>.trafficmanager.net``.
_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({"smba.trafficmanager.net", "smba.infra.gov.teams.microsoft.us"})
# Conservative conversation-ID charset (``thread.skype`` / ``thread.tacv2`` suffixes included) so a
# hostile value cannot path-traverse out of ``/v3/conversations/<id>/activities``.
_TEAMS_CONV_ID_RE = re.compile(r"^[A-Za-z0-9:@\-_.]+$")
_BF_TOKEN_SCOPE = "https://api.botframework.com/.default"
def _bf_token_request(tenant_id: str, client_id: str, client_secret: str) -> tuple[str, dict]:
"""(token URL, client-credentials form) for a Bot Framework bearer token."""
return (
f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
{"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": _BF_TOKEN_SCOPE},
)
def _is_allowed_https_host(url: str, *, check_port: bool = False) -> bool:
"""https + host in ``_ALLOWED_TEAMS_SERVICE_HOSTS`` (+ default port when asked)."""
try:
parsed = urlparse(url)
if parsed.scheme != "https" or (check_port and parsed.port not in (None, 443)):
return False
return parsed.hostname in _ALLOWED_TEAMS_SERVICE_HOSTS
except Exception:
return False
def _is_botframework_attachment_url(url: str) -> bool:
"""True if ``url`` is a Bot Framework connector attachment host (may carry the bot token)."""
return _is_allowed_https_host(url, check_port=True)
def _validate_teams_service_url(raw: str) -> Optional[str]:
"""Normalized (trailing-slash) service URL, or ``None`` if not on the allowlist."""
if not raw or not _is_allowed_https_host(raw):
return None
return raw if raw.endswith("/") else raw + "/"
class _AiohttpBridgeAdapter:
"""HttpServerAdapter bridging SDK route registrations into our aiohttp app; without it
``App()`` unconditionally imports fastapi/uvicorn and allocates a ``FastAPI()``."""
def __init__(self, aiohttp_app: "web.Application"):
self._aiohttp_app = aiohttp_app
def register_route(self, method: "HttpMethod", path: str, handler: "HttpRouteHandler") -> None:
async def _aiohttp_handler(request: "web.Request") -> "web.Response":
result: "HttpResponse" = await handler(HttpRequest(body=await request.json(), headers=dict(request.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:
"""PASSIVE probe (registry ``check_fn``): SDK + aiohttp importable? Never installs."""
return TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE
def _credentials(config) -> tuple[str, str, str]:
"""(client_id, client_secret, tenant_id): ``config.extra`` first, then the profile-scoped env.
client_id/tenant_id are read through the same scoped reader as the secret: under multiplex,
``os.environ`` holds the DEFAULT profile's app identity, and pairing it with a secondary's
secret requests a Bot Framework token for the wrong app. ``extra`` wins so a per-profile
config.yaml identity is never overridden by the process env.
"""
extra = getattr(config, "extra", {}) or {}
return (
extra.get("client_id") or _get_scoped_secret("TEAMS_CLIENT_ID", ""),
extra.get("client_secret") or _get_scoped_secret("TEAMS_CLIENT_SECRET", ""),
extra.get("tenant_id") or _get_scoped_secret("TEAMS_TENANT_ID", ""))
def validate_config(config) -> bool:
return bool(all(_credentials(config)))
def is_connected(config) -> bool:
return validate_config(config)
def _env_enablement() -> dict | None:
"""``env_enablement_fn``: seed ``PlatformConfig.extra`` from the profile's env before adapter construction
so ``gateway status`` reflects env-only setups without the SDK; ``None`` when not minimally configured.
Every identity/endpoint is per-profile (the app the secret belongs to, its regional service URL, the
cron home conversation), so a secondary is never seeded with the default profile's Teams app."""
seed = _seed_extra_from_env((
("TEAMS_CLIENT_ID", "client_id", None), ("TEAMS_CLIENT_SECRET", "client_secret", None),
("TEAMS_TENANT_ID", "tenant_id", None), ("TEAMS_PORT", "port", int), ("TEAMS_SERVICE_URL", "service_url", None),
), home_env="TEAMS_HOME_CHANNEL")
if not all(seed.get(k) for k in ("client_id", "client_secret", "tenant_id")):
return None
return seed
async def _standalone_send(
pconfig, chat_id: str, message: str, *,
thread_id: Optional[str] = None, media_files: Optional[list] = None, force_document: bool = False,
) -> Dict[str, Any]:
"""Acquire a Bot Framework bearer token and POST a single message activity; used by
``send_message_tool._send_via_adapter`` when the gateway runner is not in this process
(``hermes cron``). ``TEAMS_SERVICE_URL`` is allowlisted and ``chat_id`` charset-checked
(SSRF/path traversal). ``media_files`` / ``force_document`` are signature parity only — text-only."""
extra = getattr(pconfig, "extra", {}) or {}
client_id, client_secret, tenant_id = _credentials(pconfig)
if not (client_id and client_secret and tenant_id):
return send_error("Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required")
raw_service_url = extra.get("service_url") or _get_scoped_secret("TEAMS_SERVICE_URL", "") or _DEFAULT_TEAMS_SERVICE_URL
service_url = _validate_teams_service_url(raw_service_url)
for failed, error in (
(service_url is None, f"TEAMS_SERVICE_URL host is not on the Bot Framework allowlist; "
f"expected one of {sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"),
(not chat_id, "chat_id (conversation ID) is required"),
(not _TEAMS_CONV_ID_RE.match(chat_id or ""), "chat_id contains characters outside the Bot Framework conversation ID set"),
(not _TEAMS_CONV_ID_RE.match(tenant_id), "TEAMS_TENANT_ID contains characters outside the expected set"),
(not AIOHTTP_AVAILABLE, "aiohttp not installed")):
if failed:
return send_error(f"Teams standalone send: {error}")
token_url, token_form = _bf_token_request(tenant_id, client_id, client_secret)
activities_url = f"{service_url}v3/conversations/{chat_id}/activities"
try:
import aiohttp as _aiohttp
# Per-request timeouts so a slow STS endpoint cannot starve the activity POST.
per_request_timeout = _aiohttp.ClientTimeout(total=15.0)
async with _aiohttp.ClientSession(trust_env=gateway_trust_env()) as session:
async with session.post(
token_url, data=token_form, headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=per_request_timeout,
) as token_resp:
if token_resp.status >= 400:
body = await token_resp.text()
return send_error(f"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}")
token_payload = await token_resp.json()
access_token = token_payload.get("access_token")
if not access_token:
return send_error("Teams standalone send: token response missing access_token")
async with session.post(
activities_url, json={"type": "message", "text": message, "textFormat": "markdown"},
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
timeout=per_request_timeout,
) as send_resp:
if send_resp.status >= 400:
body = await send_resp.text()
return send_error(f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}")
send_payload = await send_resp.json()
return {"success": True, "message_id": send_payload.get("id")}
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Teams standalone send raised", exc_info=True)
return send_error(f"Teams standalone send failed: {e}")
# SDK module → names rebound into this module's globals by check_teams_requirements().
_SDK_IMPORTS = {
"microsoft_teams.apps": ("App", "ActivityContext"),
"microsoft_teams.common.http.client": ("ClientOptions",),
"microsoft_teams.api": ("MessageActivity", "ConversationReference"),
"microsoft_teams.api.activities.typing": ("TypingActivityInput",),
"microsoft_teams.api.activities.invoke.adaptive_card": ("AdaptiveCardInvokeActivity",),
"microsoft_teams.api.models.adaptive_card": ("AdaptiveCardActionCardResponse", "AdaptiveCardActionMessageResponse"),
"microsoft_teams.api.models.invoke_response": ("InvokeResponse", "AdaptiveCardInvokeResponse"),
"microsoft_teams.apps.http.adapter": ("HttpMethod", "HttpRequest", "HttpResponse", "HttpRouteHandler"),
"microsoft_teams.cards": ("AdaptiveCard", "ExecuteAction", "TextBlock")}
# NOTE: ``check_requirements`` is the
# PASSIVE probe (registry ``check_fn``, status / unit tests) — it must never trigger a pip install.
# ``check_teams_requirements`` is the ACTIVE lazy-installer, registered as ``ensure_deps_fn``: the
# registry's ``create_adapter()`` runs it when the passive probe fails, right before the gateway connects
# Teams (#79812). ``connect()`` re-checks defensively.
@contextmanager
def _suppress_third_party_dotenv() -> Iterator[None]:
"""No-op ``dotenv.load_dotenv`` while importing the Teams SDK: ``microsoft_teams.apps.app`` loads a
cwd-discovered ``.env`` at import, mutating process-global ``os.environ``. Hermes owns dotenv loading.
See #62935.
"""
try:
import dotenv as _dotenv
except ImportError:
_dotenv = None
original = getattr(_dotenv, "load_dotenv", None)
if original is None:
yield
return
_dotenv.load_dotenv = lambda *args, **kwargs: False # type: ignore[assignment]
try:
yield
finally:
_dotenv.load_dotenv = original # type: ignore[assignment]
def check_teams_requirements() -> bool:
"""Ensure the Teams SDK is importable, lazy-installing it on first use.
Lazy-installs ``microsoft-teams-apps`` via
``pm.ensure_import("teams")`` if not present, then rebinds
all module-level SDK globals on success. Returns True once the SDK (and
aiohttp) are importable, False if they couldn't be installed/imported.
``App is not None`` means symbols are already bound — ``TEAMS_SDK_AVAILABLE``
alone can be True from ``find_spec`` without an import having run yet.
"""
if App is not None and AIOHTTP_AVAILABLE:
return True
def _import() -> dict:
from aiohttp import web as _web
bindings: dict = {"web": _web, "AIOHTTP_AVAILABLE": True}
with _suppress_third_party_dotenv():
for module_name, names in _SDK_IMPORTS.items():
module = importlib.import_module(module_name)
for name in names:
try:
bindings[name] = getattr(module, name)
except AttributeError as exc: # same failure class as ``from X import Y``
raise ImportError(f"cannot import name {name!r} from {module_name!r}") from exc
bindings["TEAMS_SDK_AVAILABLE"] = True
return bindings
from pm.extras import ensure_and_bind
return ensure_and_bind("teams", _import, globals())
_CHAT_TYPES = {"personal": "dm", "groupChat": "group", "channel": "channel"}
# DOCUMENT wins over PHOTO/VIDEO/AUDIO for mixed attachments: document-context
# injection gates strictly on MessageType.DOCUMENT (same precedence as Email/Signal).
_MEDIA_KIND_PRECEDENCE = (
("document", MessageType.DOCUMENT), ("image", MessageType.PHOTO),
("video", MessageType.VIDEO), ("audio", MessageType.AUDIO))
_APPROVAL_CHOICES = {"approve_once": "once", "approve_session": "session", "approve_always": "always", "deny": "deny"}
_APPROVAL_LABELS = {
"once": "✅ Allowed (once)", "session": "✅ Allowed (session)", "always": "✅ Always allowed", "deny": "❌ Denied",
}
def _truncate(text: str, limit: int) -> str:
return text[:limit] + "..." if len(text) > limit else text
def _approval_body(cmd: str, desc: str, *, always: bool = False) -> list:
"""Adaptive Card body blocks for an approval prompt; unless ``always``, empty ``cmd``/``desc`` omit their blocks."""
body = []
if cmd or always:
body.append(TextBlock(text=f"⚠️ {EA_HEADER_TEXT}", wrap=True, weight="Bolder"))
body.append(TextBlock(text=f"```\n{cmd}\n```", wrap=True))
if desc or always:
body.append(TextBlock(text=f"{EA_REASON_LABEL_TEXT}: {desc}", wrap=True, isSubtle=True))
return body
class TeamsAdapter(BasePlatformAdapter):
"""Microsoft Teams adapter using the microsoft-teams-apps SDK."""
# Answers /p/<profile>/... on the default listener for a served secondary (shared_ingress).
serves_profile_prefix: bool = True
MAX_MESSAGE_LENGTH = 28000 # Teams text message limit (~28 KB)
splits_long_messages = True # send() chunks via truncate_message()
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("teams"))
# Kept on the instance: ``platforms.teams.extra.*`` keys are read after construction too.
self._extra: Dict[str, Any] = config.extra or {}
self._client_id, self._client_secret, self._tenant_id = _credentials(config)
# (token, expiry monotonic ts) for connector attachment auth; refreshed under
# _bf_token_lock so concurrent attachments can't stampede the STS.
self._bf_token_cache: Optional[tuple] = None
self._bf_token_lock: Optional[asyncio.Lock] = None
self._port = coerce_port(self._extra.get("port") or _get_scoped_secret("TEAMS_PORT", str(_DEFAULT_PORT)), _DEFAULT_PORT)
_raw_host = self._extra.get("host") or _get_scoped_secret("TEAMS_HOST", "") or _DEFAULT_HOST # falsy → dual-stack None
self._host: Optional[str] = str(_raw_host) if _raw_host else None
self._app: Optional["App"] = None
self._runner: Optional["web.AppRunner"] = None
self._dedup = MessageDeduplicator(max_size=1000)
# chat_id → ConversationReference so proactive cards use the right conversation type.
self._conv_refs: Dict[str, Any] = {}
self._require_mention: bool = self._parse_require_mention(config)
# Outbound activity ids (bounded) so require_mention can exempt replies to our own messages.
self._sent_ids: deque = deque(maxlen=500)
@staticmethod
def _parse_require_mention(config) -> bool:
"""TEAMS_REQUIRE_MENTION (scoped) → ``require_mention`` in config.extra → false (opt-in, same
default as TELEGRAM_REQUIRE_MENTION). Without RSC Teams only delivers mention activities to a
group bot anyway, so the gate changes nothing until the app gains ChannelMessage.Read.Group /
ChatMessage.Read.Chat and starts receiving every conversation message."""
configured = _extra_or_secret(config.extra, "require_mention", "TEAMS_REQUIRE_MENTION", False)
if isinstance(configured, bool):
return configured
return str(configured).strip().lower() not in {"false", "0", "no", "off"}
async def connect(self, *, is_reconnect: bool = False) -> bool:
# Reconnect paths reach here without create_adapter()'s installer — re-run to bind SDK globals.
check_teams_requirements()
pip = f"{sys.executable} -m pip install"
for failed, code, message in (
(App is None or ClientOptions is None, "MISSING_SDK",
f"microsoft-teams-apps could not be installed. Run: {pip} microsoft-teams-apps"),
(not AIOHTTP_AVAILABLE, "MISSING_SDK", f"aiohttp not installed. Run: {pip} aiohttp"),
(not self._client_id or not self._client_secret or not self._tenant_id, "MISSING_CREDENTIALS",
"TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required")):
if failed:
self._set_fatal_error(code, message, retryable=False)
return False
try:
# aiohttp app first — the bridge adapter wires SDK routes into it.
# Set up aiohttp app first — the bridge adapter wires SDK routes into it. client_max_size: Bot
# Framework activities are JSON (caps out well under 1 MiB); an explicit cap keeps
# oversized/chunked bodies from being buffered unbounded on a 0.0.0.0 bind (same pattern as
# webhook.py / raft, #58536/#58902).
aiohttp_app = web.Application(client_max_size=_MAX_BODY_BYTES)
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),
client=ClientOptions(headers={"User-Agent": "Hermes"}))
# Handlers (ours, then plugin on_* decorators) must be wired before initialize(),
# which registers POST /api/messages on aiohttp_app via the bridge's register_route().
@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)
self._wire_plugin_handlers(self._app)
await self._app.initialize()
# Shared-listener mode (multiplex secondary): no bind; served at /p/<profile>/api/messages.
from gateway.platforms.shared_ingress import bind_listener
self._runner = await bind_listener(self, aiohttp_app, self._host, self._port, _WEBHOOK_PATH)
self._running = True
self._mark_connected()
if self._runner is not None:
logger.info(
"[teams] Webhook server listening on %s:%d%s",
self._host or "* (all interfaces, IPv4+IPv6)", 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, exc_info=True)
return False
async def disconnect(self) -> None:
self._running = False
if self._runner:
await self._runner.cleanup()
self._runner = self._app = None
self._mark_disconnected()
logger.info("[teams] Disconnected")
async def _get_botframework_token(self) -> str:
"""Bot Framework bearer token (client credentials), cached until ~5 min before expiry; connector
attachments are NOT pre-authenticated, unlike SharePoint downloadUrls. The lock is created lazily
because ``asyncio.Lock()`` in __init__ may bind the wrong loop."""
import time
import httpx
if self._bf_token_lock is None:
self._bf_token_lock = asyncio.Lock()
async with self._bf_token_lock:
cached = self._bf_token_cache
if cached and cached[1] > time.monotonic() + 300:
return cached[0]
if not (self._client_id and self._client_secret and self._tenant_id):
raise ValueError("Missing TEAMS_CLIENT_ID/SECRET/TENANT_ID for attachment auth")
token_url, token_form = _bf_token_request(self._tenant_id, self._client_id, self._client_secret)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(token_url, data=token_form)
resp.raise_for_status()
payload = resp.json()
expires_in = float(payload.get("expires_in", 3600) or 3600)
self._bf_token_cache = (payload["access_token"], time.monotonic() + expires_in)
return self._bf_token_cache[0]
async def _fetch_attachment_bytes(self, url: str, timeout: float = 30.0) -> bytes:
"""Download attachment bytes with SSRF protection. Connector URLs get the bot's bearer token;
redirects and body size go through the shared guards (as the cache_*_from_url helpers)."""
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
from gateway.platforms.base import _ssrf_redirect_guard, _read_httpx_body_with_limit
if not is_safe_url(url):
raise ValueError("Blocked unsafe attachment URL (SSRF protection)")
headers = {"User-Agent": "Mozilla/5.0 (compatible; HermesAgent/1.0)"}
if _is_botframework_attachment_url(url):
try:
headers["Authorization"] = f"Bearer {await self._get_botframework_token()}"
except Exception as e:
logger.warning("[teams] Could not acquire Bot Framework token for attachment: %s", e)
async with create_ssrf_safe_async_client(
timeout=timeout, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}) as client:
async with client.stream("GET", url, headers=headers) as response:
response.raise_for_status()
# Never buffer .content — a lying Content-Length must not OOM the gateway.
return await _read_httpx_body_with_limit(response, media_type="attachment")
async def _on_message(self, ctx: ActivityContext[MessageActivity]) -> None:
activity = ctx.activity
# Teams writes the bot's conversation identity as ``28:<app id>`` (activity.recipient) while
# App.id is the bare app id — accept both when deciding "is this us".
recipient_id = getattr(getattr(activity, "recipient", None), "id", None)
bot_ids = {i for i in (self._app.id if self._app else None, recipient_id) if isinstance(i, str) and i}
bot_ids |= {f"28:{i}" for i in tuple(bot_ids) if not i.startswith("28:")}
if getattr(activity.from_, "id", None) in bot_ids:
return
msg_id = getattr(activity, "id", None)
if msg_id and self._dedup.is_duplicate(msg_id):
return
conv = activity.conversation
conv_id = getattr(conv, "id", None)
if conv_id: # cache the conversation reference for proactive sends (approval cards, etc.)
self._conv_refs[conv_id] = ctx.conversation_ref
text = activity.text if hasattr(activity, "text") and activity.text else ""
if self._require_mention and getattr(conv, "conversation_type", None) != "personal":
# RSC-delivered history: every channel/groupChat message arrives. Keep the ones that
# @mention the bot or reply to one of its own messages; drop the rest BEFORE the
# attachment loop so a gated post never downloads anything onto the host.
if not self._activity_mentions_bot(activity, bot_ids, text) and getattr(activity, "reply_to_id", None) not in self._sent_ids:
logger.debug("[teams] Dropping non-personal message without a bot mention (chat=%s, msg=%s)", conv_id, msg_id)
return
if "<at>" in text: # strip the <at>BotName</at> tags Teams prepends for @mentions
text = re.sub(r"<at>[^<]*</at>\s*", "", text).strip()
from_account = activity.from_
user_id = getattr(from_account, "aad_object_id", None) or getattr(from_account, "id", "")
source = self.build_source(
chat_id=conv.id,
chat_name=getattr(conv, "name", None) or "",
chat_type=_CHAT_TYPES.get(getattr(conv, "conversation_type", None) or "", "dm"),
user_id=str(user_id),
user_name=getattr(from_account, "name", None) or "",
guild_id=getattr(conv, "tenant_id", None) or self._tenant_id,
message_id=msg_id)
media: list = [m for m in [await self._cache_attachment(a) for a in getattr(activity, "attachments", None) or []] if m]
media_kinds = [kind for _, _, kind in media] # media items are (path, media_type, kind)
msg_type = next((t for kind, t in _MEDIA_KIND_PRECEDENCE if kind in media_kinds), MessageType.TEXT)
await self.handle_message(MessageEvent(
text=text, source=source, message_type=msg_type, message_id=msg_id,
media_urls=[path for path, _, _ in media], media_types=[mt for _, mt, _ in media]))
@staticmethod
def _activity_mentions_bot(activity: Any, bot_ids: set, text: str) -> bool:
"""True when a ``mention`` entity points at the bot (``mentioned.id`` is ``28:<app id>`` on the
wire; ``bot_ids`` carries both spellings). A payload with no mention entities at all falls back
to the rendered ``<at>`` tag; one that mentions only other people does not."""
mentions = [e for e in getattr(activity, "entities", None) or [] if getattr(e, "type", None) == "mention"]
if not mentions:
return "<at>" in text
return any(str(getattr(getattr(e, "mentioned", None), "id", "")) in bot_ids for e in mentions)
async def _cache_attachment(self, att: Any) -> Optional[tuple]:
"""Download + cache one inbound attachment → ``(path, media_type, kind)`` or ``None``."""
content_url = getattr(att, "content_url", None)
content_type = (getattr(att, "content_type", None) or "").lower()
att_name = getattr(att, "name", None) or ""
# Skip non-file payloads: Teams mirrors the message body as a text/html attachment,
# and cards arrive as application/vnd.microsoft.card.*
if (content_type in ("text/html", "text/plain") and not content_url) or content_type.startswith("application/vnd.microsoft.card"):
return None
if content_type == "application/vnd.microsoft.teams.file.download.info":
# Consent-free download: content carries a pre-authed SharePoint downloadUrl + file type.
content = getattr(att, "content", None)
if not isinstance(content, dict):
content = getattr(content, "__dict__", None) or {}
download_url = content.get("downloadUrl") or content.get("download_url")
file_type = (content.get("fileType") or content.get("file_type") or "").lstrip(".")
if not download_url:
return None
filename = att_name or (f"document.{file_type}" if file_type else "document")
try:
data = await self._fetch_attachment_bytes(download_url)
cached = await cache_media_bytes_async(data, filename=filename, mime_type="")
if not cached:
logger.warning("[teams] Unsupported document type for attachment '%s', skipping", filename)
return None
return cached.path, cached.media_type, cached.kind
except Exception as e:
logger.warning("[teams] Failed to cache file attachment '%s': %s", filename, e)
return None
if content_url and content_type.startswith("image/"):
try:
if _is_botframework_attachment_url(content_url):
# Connector URL needs the bot's bearer token; the generic cache helper sends none.
data = await self._fetch_attachment_bytes(content_url)
ext = content_type.split("/")[-1].split(";")[0] or "png"
cached = await cache_media_bytes_async(data, filename=att_name or f"image.{ext}", mime_type=content_type)
if not cached:
logger.warning(
"[teams] Bot Framework attachment '%s' returned data that failed image validation, skipping",
att_name or content_url)
return None
return cached.path, cached.media_type, "image"
path = await cache_image_from_url(content_url)
return (path, content_type, "image") if path else None
except Exception as e:
logger.warning("[teams] Failed to cache image attachment: %s", e)
return None
if content_url: # direct-URL non-image attachment (video/audio/document)
try:
data = await self._fetch_attachment_bytes(content_url)
cached = await cache_media_bytes_async(data, filename=att_name, mime_type=content_type)
return (cached.path, cached.media_type, cached.kind) if cached else None
except Exception as e:
logger.warning("[teams] Failed to cache attachment '%s' (%s): %s", att_name or content_url, content_type, e)
return None
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
if not self._app:
return None
return await self._send_via_conv_ref(chat_id, MessageActivityInput().add_card(card), card)
async def _send_via_conv_ref(self, chat_id: str, activity: Any, fallback: Any) -> Any:
"""Send ``activity`` through the cached ConversationReference, else ``App.send(fallback)``."""
conv_ref = self._conv_refs.get(chat_id)
if conv_ref:
result = await self._app.activity_sender.send(activity, conv_ref)
else:
result = await self._app.send(chat_id, fallback)
self._remember_sent(result)
return result
def _remember_sent(self, result: Any) -> None:
"""Track an outbound activity id (bounded deque) for the require_mention reply exemption."""
sent_id = getattr(result, "id", None)
if isinstance(sent_id, str) and sent_id:
self._sent_ids.append(sent_id)
@staticmethod
def _invoke_message(text: str) -> "InvokeResponse[AdaptiveCardActionMessageResponse]":
return InvokeResponse(status=200, body=AdaptiveCardActionMessageResponse(value=text))
@staticmethod
def _invoke_card(body: list) -> "InvokeResponse[AdaptiveCardActionMessageResponse]":
card = AdaptiveCard().with_version("1.4").with_body(body)
return InvokeResponse(status=200, body=AdaptiveCardActionCardResponse(value=card))
async def _on_card_action(
self, ctx: "ActivityContext[AdaptiveCardInvokeActivity]"
) -> "InvokeResponse[AdaptiveCardActionMessageResponse]":
from tools.approval import resolve_gateway_approval, has_blocking_approval
data = ctx.activity.value.action.data or {}
hermes_action = data.get("hermes_action", "")
session_key = data.get("session_key", "")
if not hermes_action or not session_key:
return self._invoke_message("Unknown action.")
denied = self._card_action_denied(ctx.activity.from_)
if denied:
return self._invoke_message(denied)
choice = _APPROVAL_CHOICES.get(hermes_action)
if not choice:
return self._invoke_message("Unknown action.")
if not has_blocking_approval(session_key):
return self._invoke_card([TextBlock(text="⚠️ Approval already resolved or expired.", wrap=True)])
resolve_gateway_approval(session_key, choice)
body = _approval_body(data.get("cmd", ""), data.get("desc", ""))
body.append(TextBlock(text=_APPROVAL_LABELS[choice], wrap=True, weight="Bolder"))
return self._invoke_card(body)
@staticmethod
def _card_action_denied(from_account: Any) -> Optional[str]:
"""Default-deny gate for approval clicks: require TEAMS_ALLOWED_USERS or an explicit
TEAMS_ALLOW_ALL_USERS=true opt-in, else anyone who can message the bot could approve.
Returns the user-facing denial text, or ``None`` when allowed."""
# Scoped reads: under multiplex os.environ is the DEFAULT profile's allow-all/allowlist.
if _get_scoped_secret("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"}:
return None
allowed_csv = _get_scoped_secret("TEAMS_ALLOWED_USERS", "").strip()
if not allowed_csv:
logger.warning(
"[teams] card action rejected: TEAMS_ALLOWED_USERS not configured "
"and TEAMS_ALLOW_ALL_USERS not set — default deny")
return "⛔ Approval buttons require TEAMS_ALLOWED_USERS to be configured."
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 "⛔ Not authorized."
return None
_EA_CMD_BUDGET = 2000
_EA_CARD_ACTIONS = {"once": "approve_once", "session": "approve_session", "always": "approve_always", "deny": "deny"}
_EA_CARD_STYLES = {"primary": "positive", "danger": "destructive"}
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
"""Adaptive Card: the shared text is split into its header / fenced command / reason blocks."""
if not self._app:
return SendResult(success=False, error="Teams app not initialized")
# Button data carries a truncated cmd — just enough to reconstruct the card body.
btn_data_base = {"session_key": prompt.session_key, "cmd": _truncate(prompt.command, 200), "desc": prompt.description}
actions = []
for label, choice, style in prompt.actions:
kw = {"style": self._EA_CARD_STYLES[style]} if style else {}
actions.append(ExecuteAction(
title=label, verb="hermes_approve",
data={**btn_data_base, "hermes_action": self._EA_CARD_ACTIONS[choice]}, **kw))
body = _approval_body(self._truncate_preview(prompt.command, self._EA_CMD_BUDGET), prompt.description, always=True)
body.append(TextBlock(text=format_approval_deadline_line(approval_timeout_seconds()), wrap=True))
if prompt.smart_denied:
body.append(TextBlock(text=self._EA_SMART_DENY_LINE.strip(), wrap=True))
card = AdaptiveCard().with_version("1.4").with_body(body).with_actions(actions)
try:
result = await self._send_card(prompt.chat_id, card)
return SendResult(success=True, message_id=getattr(result, "id", None) if result else None)
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")
last_message_id = None
for chunk in self.truncate_message(self.format_message(content)):
try:
if reply_to and reply_to.isdigit() and reply_to != "0":
try:
result = await self._app.reply(chat_id, reply_to, chunk)
except Exception as reply_err:
# Group chats 400 on threaded sends; the SDK has no typed HTTP errors → fall back on any.
logger.debug("Teams reply() failed, falling back to flat send: %s", reply_err)
result = await self._app.send(chat_id, chunk)
else:
result = await self._app.send(chat_id, chunk)
last_message_id = getattr(result, "id", None)
self._remember_sent(result)
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 self._app:
with suppress(Exception):
await self._app.send(chat_id, TypingActivityInput())
async def _send_media_attachment(
self, chat_id: str, source: str, default_mime: str, caption: Optional[str] = None, media_label: str = "media"
) -> SendResult:
"""Send any media file/URL as a Teams attachment (shared by send_image/video/voice/document).
Remote ``http(s)://`` URLs are attached by reference; local paths (optional ``file://`` prefix)
are base64-encoded into a data URI. MIME is guessed from the path, else ``default_mime``."""
if not self._app:
return SendResult(success=False, error="Teams app not initialized")
try:
import base64
import mimetypes
from microsoft_teams.api import Attachment, MessageActivityInput
if source.startswith(("http://", "https://")):
content_url = source
mime_type = mimetypes.guess_type(source.split("?")[0])[0] or default_mime
else:
path = source.removeprefix("file://")
mime_type = mimetypes.guess_type(path)[0] or default_mime
with open(path, "rb") as f:
content_url = f"data:{mime_type};base64,{base64.b64encode(f.read()).decode()}"
activity = MessageActivityInput().add_attachments(Attachment(content_type=mime_type, content_url=content_url))
if caption:
activity = activity.add_text(caption)
result = await self._send_via_conv_ref(chat_id, activity, activity)
return SendResult(success=True, message_id=getattr(result, "id", None))
except Exception as e:
logger.error("[teams] send_%s failed: %s", media_label, e, exc_info=True)
return SendResult(success=False, error=str(e), retryable=True)
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:
return await self._send_media_attachment(chat_id, image_url, "image/png", caption=caption, media_label="image")
async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None,
reply_to: Optional[str] = None, **kwargs) -> SendResult:
return await self.send_image(chat_id=chat_id, image_url=image_path, caption=caption, reply_to=reply_to)
async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
return await self._send_media_attachment(chat_id, video_path, "video/mp4", caption=caption, media_label="video")
async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
return await self._send_media_attachment(chat_id, audio_path, "audio/mpeg", caption=caption, media_label="voice")
async def send_document(self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None,
reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
return await self._send_media_attachment(
chat_id, file_path, "application/octet-stream", caption=caption, media_label="document")
async def get_chat_info(self, chat_id: str) -> dict:
return {"name": chat_id, "type": "unknown", "chat_id": chat_id}
_SETUP_CREDENTIALS = (
("Client ID", "TEAMS_CLIENT_ID", {}),
("Client secret", "TEAMS_CLIENT_SECRET", {"password": True}),
("Tenant ID", "TEAMS_TENANT_ID", {}))
_SETUP_INTRO = ( # "" → blank line
"You'll need the Teams CLI. If you haven't already:", " npm install -g @microsoft/teams.cli@preview",
" teams login", "", "Then expose port 3978 publicly (devtunnel / ngrok / cloudflared),", "and create your bot:",
' teams app create --name "Hermes" --endpoint "https://<tunnel>/api/messages"', "",
"The CLI will print CLIENT_ID, CLIENT_SECRET, and TENANT_ID. Paste them below.", "")
def interactive_setup() -> None:
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import prompt, prompt_yes_no, print_info, print_success, print_warning
from hermes_cli.setup_platforms import declines_reconfigure
if declines_reconfigure("Teams", "Reconfigure Teams?", "TEAMS_CLIENT_ID"):
return
for line in _SETUP_INTRO:
print_info(line) if line else print()
for label, env_key, prompt_kwargs in _SETUP_CREDENTIALS:
value = prompt(label, default=get_env_value(env_key) or "", **prompt_kwargs)
if not value:
print_warning(f"{label} is required — skipping Teams setup")
return
save_env_value(env_key, value.strip())
print()
print_info("To find your AAD object ID for the allowlist: teams status --verbose")
if prompt_yes_no("Restrict access to specific users? (recommended)", True):
allowed = prompt("Allowed AAD 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("Install the app in Teams: teams app install --id <teamsAppId>")
print_info("Restart the gateway: hermes gateway restart")
def _install_hint() -> str:
"""Point to the setup flow that requests PM's declared Teams extra."""
return "Teams SDK missing — run `hermes setup`, configure Teams, then restart the gateway"
def register(ctx) -> None:
ctx.register_platform(
name="teams", label="Microsoft Teams", adapter_factory=lambda cfg: TeamsAdapter(cfg),
check_fn=check_requirements, # PASSIVE probe — never installs
ensure_deps_fn=check_teams_requirements, # ACTIVE lazy-installer, run by create_adapter()
validate_config=validate_config, is_connected=is_connected,
required_env=["TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET", "TEAMS_TENANT_ID"],
install_hint=_install_hint(), setup_fn=interactive_setup,
env_enablement_fn=_env_enablement, # env-only setups show up in gateway status
cron_deliver_env_var="TEAMS_HOME_CHANNEL", # deliver=teams cron home-channel routing
standalone_sender_fn=_standalone_send, # out-of-process cron delivery via Bot Framework REST
allowed_users_env="TEAMS_ALLOWED_USERS", allow_all_env="TEAMS_ALLOW_ALL_USERS",
max_message_length=28000, # Teams supports up to ~28 KB per message
emoji="💼", allow_update_command=True,
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."))