diff --git a/docs/MSTEAMS.md b/docs/MSTEAMS.md
new file mode 100644
index 000000000..285553e51
--- /dev/null
+++ b/docs/MSTEAMS.md
@@ -0,0 +1,68 @@
+# Microsoft Teams (MVP)
+
+This repository includes a built-in `msteams` channel MVP for Microsoft Teams direct messages.
+
+## Current scope
+
+- Direct-message text in/out
+- Tenant-aware OAuth token acquisition
+- Conversation reference persistence for replies
+- Public HTTPS webhook support through a tunnel or reverse proxy
+
+## Not yet included
+
+- Group/channel handling
+- Attachments and cards
+- Polls
+- Richer Teams activity handling
+
+## Example config
+
+```json
+{
+ "channels": {
+ "msteams": {
+ "enabled": true,
+ "appId": "YOUR_APP_ID",
+ "appPassword": "YOUR_APP_SECRET",
+ "tenantId": "YOUR_TENANT_ID",
+ "host": "0.0.0.0",
+ "port": 3978,
+ "path": "/api/messages",
+ "allowFrom": ["*"],
+ "replyInThread": true,
+ "mentionOnlyResponse": "Hi — what can I help with?",
+ "validateInboundAuth": false,
+ "restartNotifyEnabled": false,
+ "restartNotifyPreMessage": "Nanobot agent initiated a gateway restart. I will message again when the gateway is back online.",
+ "restartNotifyPostMessage": "Nanobot gateway is back online."
+ }
+ }
+}
+```
+
+## Behavior notes
+
+- `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
+- `replyInThread: false` posts replies as normal conversation messages.
+- If `replyInThread` is enabled but no `activity_id` is stored, Nanobot falls back to a normal conversation message.
+- `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention such as `Nanobot`.
+- Set `mentionOnlyResponse` to an empty string to ignore mention-only messages.
+- `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation.
+- `validateInboundAuth: false` leaves inbound auth unenforced, which is safer while first validating a new relay, tunnel, or proxy path.
+- When enabled, Nanobot validates the inbound bearer token signature, issuer, audience, token lifetime, and `serviceUrl` claim when present.
+- `restartNotifyEnabled: true` enables optional Teams restart-notification configuration for external wrapper-script driven restarts.
+- `restartNotifyPreMessage` and `restartNotifyPostMessage` control the before/after announcement text used by that external wrapper.
+
+## Setup notes
+
+1. Create or reuse a Microsoft Teams / Azure bot app registration.
+2. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
+3. Forward that public endpoint to `http://localhost:3978/api/messages`.
+4. Start Nanobot with:
+
+```bash
+nanobot gateway
+```
+
+5. Optional: if you use an external restart wrapper (for example a script that stops and restarts the gateway), you can enable Teams restart announcements with `restartNotifyEnabled: true` and have the wrapper send `restartNotifyPreMessage` before restart and `restartNotifyPostMessage` after the gateway is back online.
diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py
new file mode 100644
index 000000000..7a746c691
--- /dev/null
+++ b/nanobot/channels/msteams.py
@@ -0,0 +1,508 @@
+"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server.
+
+Scope:
+- DM-focused MVP
+- text inbound/outbound
+- conversation reference persistence
+- sender allowlist support
+- optional inbound Bot Framework bearer-token validation
+- no attachments/cards/polls yet
+"""
+
+from __future__ import annotations
+
+import asyncio
+import html
+import json
+import re
+import threading
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+
+import httpx
+import jwt
+from loguru import logger
+from pydantic import Field
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_workspace_path
+from nanobot.config.schema import Base
+
+
+class MSTeamsConfig(Base):
+ """Microsoft Teams channel configuration."""
+
+ enabled: bool = False
+ app_id: str = ""
+ app_password: str = ""
+ tenant_id: str = ""
+ host: str = "0.0.0.0"
+ port: int = 3978
+ path: str = "/api/messages"
+ allow_from: list[str] = Field(default_factory=list)
+ reply_in_thread: bool = True
+ mention_only_response: str = "Hi — what can I help with?"
+ validate_inbound_auth: bool = False
+ restart_notify_enabled: bool = False
+ restart_notify_pre_message: str = (
+ "Nanobot agent initiated a gateway restart. I will message again when the gateway is back online."
+ )
+ restart_notify_post_message: str = "Nanobot gateway is back online."
+
+
+@dataclass
+class ConversationRef:
+ """Minimal stored conversation reference for replies."""
+
+ service_url: str
+ conversation_id: str
+ bot_id: str | None = None
+ activity_id: str | None = None
+ conversation_type: str | None = None
+ tenant_id: str | None = None
+
+
+class MSTeamsChannel(BaseChannel):
+ """Microsoft Teams channel (DM-first MVP)."""
+
+ name = "msteams"
+ display_name = "Microsoft Teams"
+
+ @classmethod
+ def default_config(cls) -> dict[str, Any]:
+ return MSTeamsConfig().model_dump(by_alias=True)
+
+ def __init__(self, config: Any, bus: MessageBus):
+ if isinstance(config, dict):
+ config = MSTeamsConfig.model_validate(config)
+ super().__init__(config, bus)
+ self.config: MSTeamsConfig = config
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._server: ThreadingHTTPServer | None = None
+ self._server_thread: threading.Thread | None = None
+ self._http: httpx.AsyncClient | None = None
+ self._token: str | None = None
+ self._token_expires_at: float = 0.0
+ self._botframework_openid_config_url = (
+ "https://login.botframework.com/v1/.well-known/openidconfiguration"
+ )
+ self._botframework_openid_config: dict[str, Any] | None = None
+ self._botframework_openid_config_expires_at: float = 0.0
+ self._botframework_jwks: dict[str, Any] | None = None
+ self._botframework_jwks_expires_at: float = 0.0
+ self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json"
+ self._refs_path.parent.mkdir(parents=True, exist_ok=True)
+ self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
+
+ async def start(self) -> None:
+ """Start the Teams webhook listener."""
+ if not self.config.app_id or not self.config.app_password:
+ logger.error("MSTeams app_id/app_password not configured")
+ return
+
+ self._loop = asyncio.get_running_loop()
+ self._http = httpx.AsyncClient(timeout=30.0)
+ self._running = True
+
+ channel = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_POST(self) -> None:
+ if self.path != channel.config.path:
+ self.send_response(404)
+ self.end_headers()
+ return
+
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ raw = self.rfile.read(length) if length > 0 else b"{}"
+ payload = json.loads(raw.decode("utf-8"))
+ except Exception as e:
+ logger.warning("MSTeams invalid request body: {}", e)
+ self.send_response(400)
+ self.end_headers()
+ return
+
+ auth_header = self.headers.get("Authorization", "")
+ if channel.config.validate_inbound_auth:
+ try:
+ fut = asyncio.run_coroutine_threadsafe(
+ channel._validate_inbound_auth(auth_header, payload),
+ channel._loop,
+ )
+ fut.result(timeout=15)
+ except Exception as e:
+ logger.warning("MSTeams inbound auth validation failed: {}", e)
+ self.send_response(401)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(b'{"error":"unauthorized"}')
+ return
+ try:
+ fut = asyncio.run_coroutine_threadsafe(
+ channel._handle_activity(payload),
+ channel._loop,
+ )
+ fut.result(timeout=15)
+ except Exception as e:
+ logger.warning("MSTeams activity handling failed: {}", e)
+
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(b"{}")
+
+ def log_message(self, format: str, *args: Any) -> None:
+ return
+
+ self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler)
+ self._server_thread = threading.Thread(
+ target=self._server.serve_forever,
+ name="nanobot-msteams",
+ daemon=True,
+ )
+ self._server_thread.start()
+
+ logger.info(
+ "MSTeams webhook listening on http://{}:{}{}",
+ self.config.host,
+ self.config.port,
+ self.config.path,
+ )
+
+ while self._running:
+ await asyncio.sleep(1)
+
+ async def stop(self) -> None:
+ """Stop the channel."""
+ self._running = False
+ if self._server:
+ self._server.shutdown()
+ self._server.server_close()
+ self._server = None
+ if self._server_thread and self._server_thread.is_alive():
+ self._server_thread.join(timeout=2)
+ self._server_thread = None
+ if self._http:
+ await self._http.aclose()
+ self._http = None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a plain text reply into an existing Teams conversation."""
+ if not self._http:
+ logger.warning("MSTeams HTTP client not initialized")
+ return
+
+ ref = self._conversation_refs.get(str(msg.chat_id))
+ if not ref:
+ logger.warning("MSTeams conversation ref not found for chat_id={}", msg.chat_id)
+ return
+
+ token = await self._get_access_token()
+ base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
+ use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
+ url = (
+ f"{base_url}/{ref.activity_id}"
+ if use_thread_reply
+ else base_url
+ )
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+ payload = {
+ "type": "message",
+ "text": msg.content or " ",
+ }
+ if use_thread_reply:
+ payload["replyToId"] = ref.activity_id
+
+ try:
+ resp = await self._http.post(url, headers=headers, json=payload)
+ resp.raise_for_status()
+ logger.info("MSTeams message sent to {}", ref.conversation_id)
+ except Exception as e:
+ logger.error("MSTeams send failed: {}", e)
+
+ async def _handle_activity(self, activity: dict[str, Any]) -> None:
+ """Handle inbound Teams/Bot Framework activity."""
+ if activity.get("type") != "message":
+ return
+
+ conversation = activity.get("conversation") or {}
+ from_user = activity.get("from") or {}
+ recipient = activity.get("recipient") or {}
+ channel_data = activity.get("channelData") or {}
+
+ sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
+ conversation_id = str(conversation.get("id") or "").strip()
+ text = str(activity.get("text") or "").strip()
+ service_url = str(activity.get("serviceUrl") or "").strip()
+ activity_id = str(activity.get("id") or "").strip()
+ conversation_type = str(conversation.get("conversationType") or "").strip()
+
+ if not sender_id or not conversation_id or not service_url:
+ return
+
+ if recipient.get("id") and from_user.get("id") == recipient.get("id"):
+ return
+
+ # DM-only MVP: ignore group/channel traffic for now
+ if conversation_type and conversation_type not in ("personal", ""):
+ logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
+ return
+
+ if not self.is_allowed(sender_id):
+ return
+
+ text = self._sanitize_inbound_text(activity)
+ if not text:
+ text = self.config.mention_only_response.strip()
+ if not text:
+ logger.debug("MSTeams ignoring empty message after Teams text sanitization")
+ return
+
+ self._conversation_refs[conversation_id] = ConversationRef(
+ service_url=service_url,
+ conversation_id=conversation_id,
+ bot_id=str(recipient.get("id") or "") or None,
+ activity_id=activity_id or None,
+ conversation_type=conversation_type or None,
+ tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
+ )
+ self._save_refs()
+
+ await self._handle_message(
+ sender_id=sender_id,
+ chat_id=conversation_id,
+ content=text,
+ metadata={
+ "msteams": {
+ "activity_id": activity_id,
+ "conversation_id": conversation_id,
+ "conversation_type": conversation_type or "personal",
+ "from_name": from_user.get("name"),
+ }
+ },
+ )
+
+ def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str:
+ """Extract the user-authored text from a Teams activity."""
+ text = str(activity.get("text") or "")
+ text = self._strip_possible_bot_mention(text)
+
+ channel_data = activity.get("channelData") or {}
+ reply_to_id = str(activity.get("replyToId") or "").strip()
+ normalized_preview = html.unescape(text).replace("&rsquo", "’").strip()
+ normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
+ preview_lines = [line.strip() for line in normalized_preview.split("\n")]
+ while preview_lines and not preview_lines[0]:
+ preview_lines.pop(0)
+ first_line = preview_lines[0] if preview_lines else ""
+ looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("FWDIOC-BOT")
+
+ if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper:
+ text = self._normalize_teams_reply_quote(text)
+
+ return text.strip()
+
+ def _strip_possible_bot_mention(self, text: str) -> str:
+ """Remove simple Teams mention markup from message text."""
+ cleaned = re.sub(r"]*>.*?", " ", text, flags=re.IGNORECASE | re.DOTALL)
+ cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned)
+ cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
+ return cleaned.strip()
+
+ def _normalize_teams_reply_quote(self, text: str) -> str:
+ """Normalize Teams quoted replies into a compact structured form."""
+ cleaned = html.unescape(text).replace("&rsquo", "’").strip()
+ if not cleaned:
+ return ""
+
+ normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n")
+ lines = [line.strip() for line in normalized_newlines.split("\n")]
+ while lines and not lines[0]:
+ lines.pop(0)
+
+ if len(lines) >= 2 and lines[0].lower().startswith("replying to "):
+ quoted = lines[0][len("replying to ") :].strip(" :")
+ reply = "\n".join(lines[1:]).strip()
+ return self._format_reply_with_quote(quoted, reply)
+
+ if lines and lines[0].strip().startswith("FWDIOC-BOT"):
+ body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else ""
+ body = body.lstrip()
+ parts = re.split(r"\n\s*\n", body, maxsplit=1)
+ if len(parts) == 2:
+ quoted = re.sub(r"\s+", " ", parts[0]).strip()
+ reply = re.sub(r"\s+", " ", parts[1]).strip()
+ if quoted or reply:
+ return self._format_reply_with_quote(quoted, reply)
+
+ body_lines = [line.strip() for line in body.split("\n") if line.strip()]
+ if body_lines:
+ quoted = " ".join(body_lines[:-1]).strip()
+ reply = body_lines[-1].strip()
+ if quoted and reply:
+ return self._format_reply_with_quote(quoted, reply)
+
+ compact = re.sub(r"\s+", " ", normalized_newlines).strip()
+ if compact.startswith("FWDIOC-BOT "):
+ compact = compact[len("FWDIOC-BOT ") :].strip()
+
+ marker = " Reply with quote test"
+ if compact.endswith(marker):
+ quoted = compact[: -len(marker)].strip()
+ reply = marker.strip()
+ return self._format_reply_with_quote(quoted, reply)
+
+ return cleaned
+
+ def _format_reply_with_quote(self, quoted: str, reply: str) -> str:
+ """Format a quoted reply for the model without Teams wrapper noise."""
+ quoted = quoted.strip()
+ reply = reply.strip()
+ if quoted and reply:
+ return f"User is replying to: {quoted}\nUser reply: {reply}"
+ if reply:
+ return reply
+ return quoted
+
+ async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
+ """Validate inbound Bot Framework bearer token."""
+ if not auth_header.lower().startswith("bearer "):
+ raise ValueError("missing bearer token")
+
+ token = auth_header.split(" ", 1)[1].strip()
+ if not token:
+ raise ValueError("empty bearer token")
+
+ header = jwt.get_unverified_header(token)
+ kid = str(header.get("kid") or "").strip()
+ if not kid:
+ raise ValueError("missing token kid")
+
+ jwks = await self._get_botframework_jwks()
+ keys = jwks.get("keys") or []
+ jwk = next((key for key in keys if key.get("kid") == kid), None)
+ if not jwk:
+ raise ValueError(f"signing key not found for kid={kid}")
+
+ public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
+ claims = jwt.decode(
+ token,
+ key=public_key,
+ algorithms=["RS256"],
+ audience=self.config.app_id,
+ issuer="https://api.botframework.com",
+ options={
+ "require": ["exp", "nbf", "iss", "aud"],
+ },
+ )
+
+ claim_service_url = str(
+ claims.get("serviceurl") or claims.get("serviceUrl") or "",
+ ).strip()
+ activity_service_url = str(activity.get("serviceUrl") or "").strip()
+ if claim_service_url and activity_service_url and claim_service_url != activity_service_url:
+ raise ValueError("serviceUrl claim mismatch")
+
+ async def _get_botframework_openid_config(self) -> dict[str, Any]:
+ """Fetch and cache Bot Framework OpenID configuration."""
+ import time
+
+ now = time.time()
+ if self._botframework_openid_config and now < self._botframework_openid_config_expires_at:
+ return self._botframework_openid_config
+
+ if not self._http:
+ raise RuntimeError("MSTeams HTTP client not initialized")
+
+ resp = await self._http.get(self._botframework_openid_config_url)
+ resp.raise_for_status()
+ self._botframework_openid_config = resp.json()
+ self._botframework_openid_config_expires_at = now + 3600
+ return self._botframework_openid_config
+
+ async def _get_botframework_jwks(self) -> dict[str, Any]:
+ """Fetch and cache Bot Framework JWKS."""
+ import time
+
+ now = time.time()
+ if self._botframework_jwks and now < self._botframework_jwks_expires_at:
+ return self._botframework_jwks
+
+ if not self._http:
+ raise RuntimeError("MSTeams HTTP client not initialized")
+
+ openid_config = await self._get_botframework_openid_config()
+ jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
+ if not jwks_uri:
+ raise RuntimeError("Bot Framework OpenID config missing jwks_uri")
+
+ resp = await self._http.get(jwks_uri)
+ resp.raise_for_status()
+ self._botframework_jwks = resp.json()
+ self._botframework_jwks_expires_at = now + 3600
+ return self._botframework_jwks
+ def _load_refs(self) -> dict[str, ConversationRef]:
+ """Load stored conversation references."""
+ if not self._refs_path.exists():
+ return {}
+ try:
+ data = json.loads(self._refs_path.read_text(encoding="utf-8"))
+ out: dict[str, ConversationRef] = {}
+ for key, value in data.items():
+ out[key] = ConversationRef(**value)
+ return out
+ except Exception as e:
+ logger.warning("Failed to load MSTeams conversation refs: {}", e)
+ return {}
+
+ def _save_refs(self) -> None:
+ """Persist conversation references."""
+ try:
+ data = {
+ key: {
+ "service_url": ref.service_url,
+ "conversation_id": ref.conversation_id,
+ "bot_id": ref.bot_id,
+ "activity_id": ref.activity_id,
+ "conversation_type": ref.conversation_type,
+ "tenant_id": ref.tenant_id,
+ }
+ for key, ref in self._conversation_refs.items()
+ }
+ self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
+ except Exception as e:
+ logger.warning("Failed to save MSTeams conversation refs: {}", e)
+
+ async def _get_access_token(self) -> str:
+ """Fetch an access token for Bot Framework / Azure Bot auth."""
+ import time
+
+ now = time.time()
+ if self._token and now < self._token_expires_at - 60:
+ return self._token
+
+ if not self._http:
+ raise RuntimeError("MSTeams HTTP client not initialized")
+
+ tenant = (self.config.tenant_id or "").strip() or "botframework.com"
+ token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
+ data = {
+ "grant_type": "client_credentials",
+ "client_id": self.config.app_id,
+ "client_secret": self.config.app_password,
+ "scope": "https://api.botframework.com/.default",
+ }
+ resp = await self._http.post(token_url, data=data)
+ resp.raise_for_status()
+ payload = resp.json()
+ self._token = payload["access_token"]
+ self._token_expires_at = now + int(payload.get("expires_in", 3600))
+ return self._token
diff --git a/tests/test_msteams.py b/tests/test_msteams.py
new file mode 100644
index 000000000..1ad38244f
--- /dev/null
+++ b/tests/test_msteams.py
@@ -0,0 +1,684 @@
+import json
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.channels.msteams import ConversationRef, MSTeamsChannel, MSTeamsConfig
+
+
+class DummyBus:
+ def __init__(self):
+ self.inbound = []
+
+ async def publish_inbound(self, msg):
+ self.inbound.append(msg)
+
+
+class FakeResponse:
+ def __init__(self, payload):
+ self._payload = payload
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return self._payload
+
+
+class FakeHttpClient:
+ def __init__(self, payload=None):
+ self.payload = payload or {"access_token": "tok", "expires_in": 3600}
+ self.calls = []
+
+ async def post(self, url, **kwargs):
+ self.calls.append((url, kwargs))
+ return FakeResponse(self.payload)
+
+
+@pytest.mark.asyncio
+async def test_handle_activity_personal_message_publishes_and_stores_ref(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "type": "message",
+ "id": "activity-1",
+ "text": "Hello from Teams",
+ "serviceUrl": "https://smba.trafficmanager.net/amer/",
+ "conversation": {
+ "id": "conv-123",
+ "conversationType": "personal",
+ },
+ "from": {
+ "id": "29:user-id",
+ "aadObjectId": "aad-user-1",
+ "name": "Bob",
+ },
+ "recipient": {
+ "id": "28:bot-id",
+ "name": "nanobot",
+ },
+ "channelData": {
+ "tenant": {"id": "tenant-id"},
+ },
+ }
+
+ await ch._handle_activity(activity)
+
+ assert len(bus.inbound) == 1
+ msg = bus.inbound[0]
+ assert msg.channel == "msteams"
+ assert msg.sender_id == "aad-user-1"
+ assert msg.chat_id == "conv-123"
+ assert msg.content == "Hello from Teams"
+ assert msg.metadata["msteams"]["conversation_id"] == "conv-123"
+ assert "conv-123" in ch._conversation_refs
+
+ saved = json.loads((tmp_path / "state" / "msteams_conversations.json").read_text(encoding="utf-8"))
+ assert saved["conv-123"]["conversation_id"] == "conv-123"
+ assert saved["conv-123"]["tenant_id"] == "tenant-id"
+
+
+@pytest.mark.asyncio
+async def test_handle_activity_ignores_group_messages(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "type": "message",
+ "id": "activity-2",
+ "text": "Hello group",
+ "serviceUrl": "https://smba.trafficmanager.net/amer/",
+ "conversation": {
+ "id": "conv-group",
+ "conversationType": "channel",
+ },
+ "from": {
+ "id": "29:user-id",
+ "aadObjectId": "aad-user-1",
+ "name": "Bob",
+ },
+ "recipient": {
+ "id": "28:bot-id",
+ "name": "nanobot",
+ },
+ }
+
+ await ch._handle_activity(activity)
+
+ assert bus.inbound == []
+ assert ch._conversation_refs == {}
+
+
+@pytest.mark.asyncio
+async def test_handle_activity_mention_only_uses_default_response(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "type": "message",
+ "id": "activity-3",
+ "text": "Nanobot",
+ "serviceUrl": "https://smba.trafficmanager.net/amer/",
+ "conversation": {
+ "id": "conv-empty",
+ "conversationType": "personal",
+ },
+ "from": {
+ "id": "29:user-id",
+ "aadObjectId": "aad-user-1",
+ "name": "Bob",
+ },
+ "recipient": {
+ "id": "28:bot-id",
+ "name": "nanobot",
+ },
+ }
+
+ await ch._handle_activity(activity)
+
+ assert len(bus.inbound) == 1
+ assert bus.inbound[0].content == "Hi — what can I help with?"
+ assert "conv-empty" in ch._conversation_refs
+
+
+@pytest.mark.asyncio
+async def test_handle_activity_mention_only_ignores_when_response_disabled(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "mentionOnlyResponse": " ",
+ },
+ bus,
+ )
+
+ activity = {
+ "type": "message",
+ "id": "activity-4",
+ "text": "Nanobot",
+ "serviceUrl": "https://smba.trafficmanager.net/amer/",
+ "conversation": {
+ "id": "conv-empty-disabled",
+ "conversationType": "personal",
+ },
+ "from": {
+ "id": "29:user-id",
+ "aadObjectId": "aad-user-1",
+ "name": "Bob",
+ },
+ "recipient": {
+ "id": "28:bot-id",
+ "name": "nanobot",
+ },
+ }
+
+ await ch._handle_activity(activity)
+
+ assert bus.inbound == []
+ assert ch._conversation_refs == {}
+
+
+def test_strip_possible_bot_mention_removes_generic_at_tags(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ assert ch._strip_possible_bot_mention("Nanobot hello") == "hello"
+ assert ch._strip_possible_bot_mention("hi Some Bot there") == "hi there"
+
+
+def test_sanitize_inbound_text_keeps_normal_inline_message(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": "Nanobot normal inline message",
+ "channelData": {},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == "normal inline message"
+
+
+def test_sanitize_inbound_text_normalizes_fwdioc_wrapper_without_reply_metadata(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": "FWDIOC-BOT \r\nQuoted prior message\r\n\r\nThis is a reply with quote test",
+ "channelData": {},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == (
+ "User is replying to: Quoted prior message\n"
+ "User reply: This is a reply with quote test"
+ )
+
+
+def test_sanitize_inbound_text_structures_reply_quote_prefix(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": "Replying to Bob Smith\nactual reply text",
+ "replyToId": "parent-activity",
+ "channelData": {"messageType": "reply"},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == "User is replying to: Bob Smith\nUser reply: actual reply text"
+
+
+def test_sanitize_inbound_text_structures_live_fwdioc_quote_shape(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": "FWDIOC-BOT Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically. Reply with quote test",
+ "replyToId": "parent-activity",
+ "channelData": {"messageType": "reply"},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == (
+ "User is replying to: Got it. I’ll watch for the exact text reply with quote test and then inspect that turn specifically.\n"
+ "User reply: Reply with quote test"
+ )
+
+
+def test_sanitize_inbound_text_structures_multiline_fwdioc_quote_shape(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": (
+ "FWDIOC-BOT\r\n"
+ "Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
+ "Next best step: • send one more real reply-with-quote message in Teams • I&rsquo…\r\n"
+ "\r\n"
+ "This is a reply with quote"
+ ),
+ "replyToId": "parent-activity",
+ "channelData": {"messageType": "reply"},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == (
+ "User is replying to: Understood — then the restart already happened, and the new Teams quote normalization should now be live. "
+ "Next best step: • send one more real reply-with-quote message in Teams • I’…\n"
+ "User reply: This is a reply with quote"
+ )
+
+
+def test_sanitize_inbound_text_structures_exact_live_crlf_fwdioc_shape(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ activity = {
+ "text": (
+ "FWDIOC-BOT \r\n"
+ "Please send one real reply-with-quote message in Teams. That single test should be enough now: "
+ "• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\r\n"
+ "\r\n"
+ "This is a reply with quote test"
+ ),
+ "replyToId": "parent-activity",
+ "channelData": {"messageType": "reply"},
+ }
+
+ assert ch._sanitize_inbound_text(activity) == (
+ "User is replying to: Please send one real reply-with-quote message in Teams. That single test should be enough now: "
+ "• I’ll check the new MSTeams sanitized inbound text ... log • and compare it to the prompt…\n"
+ "User reply: This is a reply with quote test"
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_access_token_uses_configured_tenant(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-123",
+ "allowFrom": ["*"],
+ },
+ bus,
+ )
+
+ fake_http = FakeHttpClient()
+ ch._http = fake_http
+
+ token = await ch._get_access_token()
+
+ assert token == "tok"
+ assert len(fake_http.calls) == 1
+ url, kwargs = fake_http.calls[0]
+ assert url == "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token"
+ assert kwargs["data"]["client_id"] == "app-id"
+ assert kwargs["data"]["client_secret"] == "secret"
+ assert kwargs["data"]["scope"] == "https://api.botframework.com/.default"
+
+
+@pytest.mark.asyncio
+async def test_send_replies_to_activity_when_reply_in_thread_enabled(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "replyInThread": True,
+ },
+ bus,
+ )
+
+ fake_http = FakeHttpClient()
+ ch._http = fake_http
+ ch._token = "tok"
+ ch._token_expires_at = 9999999999
+ ch._conversation_refs["conv-123"] = ConversationRef(
+ service_url="https://smba.trafficmanager.net/amer/",
+ conversation_id="conv-123",
+ activity_id="activity-1",
+ )
+
+ await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
+
+ assert len(fake_http.calls) == 1
+ url, kwargs = fake_http.calls[0]
+ assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1"
+ assert kwargs["headers"]["Authorization"] == "Bearer tok"
+ assert kwargs["json"]["text"] == "Reply text"
+ assert kwargs["json"]["replyToId"] == "activity-1"
+
+
+@pytest.mark.asyncio
+async def test_send_posts_to_conversation_when_thread_reply_disabled(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "replyInThread": False,
+ },
+ bus,
+ )
+
+ fake_http = FakeHttpClient()
+ ch._http = fake_http
+ ch._token = "tok"
+ ch._token_expires_at = 9999999999
+ ch._conversation_refs["conv-123"] = ConversationRef(
+ service_url="https://smba.trafficmanager.net/amer/",
+ conversation_id="conv-123",
+ activity_id="activity-1",
+ )
+
+ await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
+
+ assert len(fake_http.calls) == 1
+ url, kwargs = fake_http.calls[0]
+ assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
+ assert kwargs["headers"]["Authorization"] == "Bearer tok"
+ assert kwargs["json"]["text"] == "Reply text"
+ assert "replyToId" not in kwargs["json"]
+
+
+@pytest.mark.asyncio
+async def test_send_posts_to_conversation_when_thread_reply_enabled_but_no_activity_id(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "replyInThread": True,
+ },
+ bus,
+ )
+
+ fake_http = FakeHttpClient()
+ ch._http = fake_http
+ ch._token = "tok"
+ ch._token_expires_at = 9999999999
+ ch._conversation_refs["conv-123"] = ConversationRef(
+ service_url="https://smba.trafficmanager.net/amer/",
+ conversation_id="conv-123",
+ activity_id=None,
+ )
+
+ await ch.send(OutboundMessage(channel="msteams", chat_id="conv-123", content="Reply text"))
+
+ assert len(fake_http.calls) == 1
+ url, kwargs = fake_http.calls[0]
+ assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
+ assert kwargs["headers"]["Authorization"] == "Bearer tok"
+ assert kwargs["json"]["text"] == "Reply text"
+ assert "replyToId" not in kwargs["json"]
+
+
+def _make_test_rsa_jwk(kid: str = "test-kid"):
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ public_key = private_key.public_key()
+ jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(public_key))
+ jwk["kid"] = kid
+ jwk["use"] = "sig"
+ jwk["kty"] = "RSA"
+ jwk["alg"] = "RS256"
+ return private_key, jwk
+
+
+@pytest.mark.asyncio
+async def test_validate_inbound_auth_accepts_observed_botframework_shape(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "validateInboundAuth": True,
+ },
+ bus,
+ )
+
+ private_key, jwk = _make_test_rsa_jwk()
+ ch._botframework_jwks = {"keys": [jwk]}
+ ch._botframework_jwks_expires_at = 9999999999
+
+ service_url = "https://smba.trafficmanager.net/amer/tenant/"
+ token = jwt.encode(
+ {
+ "iss": "https://api.botframework.com",
+ "aud": "app-id",
+ "serviceurl": service_url,
+ "nbf": 1700000000,
+ "exp": 4100000000,
+ },
+ private_key,
+ algorithm="RS256",
+ headers={"kid": jwk["kid"]},
+ )
+
+ await ch._validate_inbound_auth(
+ f"Bearer {token}",
+ {"serviceUrl": service_url},
+ )
+
+
+@pytest.mark.asyncio
+async def test_validate_inbound_auth_rejects_service_url_mismatch(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "validateInboundAuth": True,
+ },
+ bus,
+ )
+
+ private_key, jwk = _make_test_rsa_jwk()
+ ch._botframework_jwks = {"keys": [jwk]}
+ ch._botframework_jwks_expires_at = 9999999999
+
+ token = jwt.encode(
+ {
+ "iss": "https://api.botframework.com",
+ "aud": "app-id",
+ "serviceurl": "https://smba.trafficmanager.net/amer/tenant-a/",
+ "nbf": 1700000000,
+ "exp": 4100000000,
+ },
+ private_key,
+ algorithm="RS256",
+ headers={"kid": jwk["kid"]},
+ )
+
+ with pytest.raises(ValueError, match="serviceUrl claim mismatch"):
+ await ch._validate_inbound_auth(
+ f"Bearer {token}",
+ {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant-b/"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_validate_inbound_auth_rejects_missing_bearer_token(tmp_path, monkeypatch):
+ monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path)
+
+ bus = DummyBus()
+ ch = MSTeamsChannel(
+ {
+ "enabled": True,
+ "appId": "app-id",
+ "appPassword": "secret",
+ "tenantId": "tenant-id",
+ "allowFrom": ["*"],
+ "validateInboundAuth": True,
+ },
+ bus,
+ )
+
+ with pytest.raises(ValueError, match="missing bearer token"):
+ await ch._validate_inbound_auth("", {"serviceUrl": "https://smba.trafficmanager.net/amer/tenant/"})
+
+
+def test_msteams_default_config_includes_restart_notify_fields():
+ cfg = MSTeamsChannel.default_config()
+
+ assert cfg["restartNotifyEnabled"] is False
+ assert "restartNotifyPreMessage" in cfg
+ assert "restartNotifyPostMessage" in cfg
+
+
+def test_msteams_config_accepts_restart_notify_aliases():
+ cfg = MSTeamsConfig.model_validate(
+ {
+ "restartNotifyEnabled": True,
+ "restartNotifyPreMessage": "Restarting now.",
+ "restartNotifyPostMessage": "Back online.",
+ }
+ )
+
+ assert cfg.restart_notify_enabled is True
+ assert cfg.restart_notify_pre_message == "Restarting now."
+ assert cfg.restart_notify_post_message == "Back online."