diff --git a/migrations/add_teams_accounts.sql b/migrations/add_teams_accounts.sql
new file mode 100644
index 0000000..31c8268
--- /dev/null
+++ b/migrations/add_teams_accounts.sql
@@ -0,0 +1,17 @@
+-- Migration: Add Teams accounts table for Microsoft Teams Bot integration
+-- Run this in Supabase SQL editor before deploying Teams bot feature
+
+CREATE TABLE teams_accounts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ teams_user_id TEXT,
+ teams_tenant_id TEXT,
+ teams_service_url TEXT NOT NULL,
+ teams_conversation_id TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT teams_accounts_user_id_unique UNIQUE (user_id)
+);
+
+CREATE INDEX idx_teams_accounts_user_id ON teams_accounts (user_id);
diff --git a/requirements.txt b/requirements.txt
index 2ec3fa4..8f0f55c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -40,3 +40,6 @@ fastapi==0.115.0
uvicorn==0.32.0
jinja2==3.1.4
python-multipart==0.0.9
+
+# Teams Bot JWT validation
+PyJWT>=2.8.0
diff --git a/src/config.py b/src/config.py
index ae3d182..1eb65cc 100644
--- a/src/config.py
+++ b/src/config.py
@@ -76,6 +76,11 @@ class Settings(BaseSettings):
telegram_webhook_secret: str = "" # Random string to validate incoming webhooks
telegram_webhook_url: str = "" # Base URL of the app, e.g. https://app.example.com
+ # Microsoft Teams Bot
+ teams_enabled: bool = False
+ microsoft_app_id: str = ""
+ microsoft_app_secret: str = ""
+
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
diff --git a/src/database/client.py b/src/database/client.py
index 44181d8..b84b8cb 100644
--- a/src/database/client.py
+++ b/src/database/client.py
@@ -874,6 +874,52 @@ class DatabaseClient:
logger.info(f"Deleted Telegram account for user: {user_id}")
return True
+ # ==================== TEAMS ACCOUNTS ====================
+
+ async def get_teams_account(self, user_id: UUID) -> Optional['TeamsAccount']:
+ """Get Teams account for user."""
+ from src.database.models import TeamsAccount
+ result = await asyncio.to_thread(
+ lambda: self.client.table("teams_accounts").select("*")
+ .eq("user_id", str(user_id)).eq("is_active", True).execute()
+ )
+ if result.data:
+ return TeamsAccount(**result.data[0])
+ return None
+
+ async def save_teams_account(self, account: 'TeamsAccount') -> 'TeamsAccount':
+ """Create or update a Teams account connection."""
+ from src.database.models import TeamsAccount
+ data = account.model_dump(exclude={'id', 'created_at', 'updated_at'}, exclude_none=True)
+ data['user_id'] = str(data['user_id'])
+
+ existing = await asyncio.to_thread(
+ lambda: self.client.table("teams_accounts").select("id")
+ .eq("user_id", str(account.user_id)).execute()
+ )
+
+ if existing.data:
+ result = await asyncio.to_thread(
+ lambda: self.client.table("teams_accounts").update(data)
+ .eq("user_id", str(account.user_id)).execute()
+ )
+ else:
+ result = await asyncio.to_thread(
+ lambda: self.client.table("teams_accounts").insert(data).execute()
+ )
+
+ logger.info(f"Saved Teams account for user: {account.user_id}")
+ return TeamsAccount(**result.data[0])
+
+ async def delete_teams_account(self, user_id: UUID) -> bool:
+ """Delete Teams account connection for user."""
+ await asyncio.to_thread(
+ lambda: self.client.table("teams_accounts").delete()
+ .eq("user_id", str(user_id)).execute()
+ )
+ logger.info(f"Deleted Teams account for user: {user_id}")
+ return True
+
# ==================== USERS ====================
async def get_user(self, user_id: UUID) -> Optional[User]:
diff --git a/src/database/models.py b/src/database/models.py
index b74faad..5f658a3 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -119,6 +119,19 @@ class TelegramAccount(DBModel):
last_error_at: Optional[datetime] = None
+class TeamsAccount(DBModel):
+ """Microsoft Teams account connection for bot access."""
+ id: Optional[UUID] = None
+ user_id: UUID
+ teams_user_id: Optional[str] = None
+ teams_tenant_id: Optional[str] = None
+ teams_service_url: str
+ teams_conversation_id: str
+ is_active: bool = True
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
+
+
class User(DBModel):
"""User model - combines auth.users data with profile data.
diff --git a/src/services/teams_service.py b/src/services/teams_service.py
new file mode 100644
index 0000000..fecb985
--- /dev/null
+++ b/src/services/teams_service.py
@@ -0,0 +1,217 @@
+"""Microsoft Teams Bot service for approval notifications via Adaptive Cards."""
+import time
+from typing import Optional, TYPE_CHECKING
+
+import httpx
+from loguru import logger
+
+from src.config import settings
+
+if TYPE_CHECKING:
+ from src.database.models import TeamsAccount
+
+# Bot auth token cache: (token_string, expiry_timestamp)
+_bot_token_cache: Optional[tuple[str, float]] = None
+
+# JWKS cache: (keys_list, expiry_timestamp)
+_jwks_cache: Optional[tuple[list, float]] = None
+_JWKS_TTL = 3600 # 1 hour
+
+
+class TeamsService:
+ """Handles Microsoft Teams bot interactions for post approval notifications."""
+
+ def __init__(self, app_id: str, app_secret: str):
+ self._app_id = app_id
+ self._app_secret = app_secret
+
+ # ==================== BOT AUTH TOKEN ====================
+
+ async def get_bot_token(self) -> str:
+ """Get a valid bot framework OAuth token, using module-level cache (~50 min TTL)."""
+ global _bot_token_cache
+
+ now = time.monotonic()
+ if _bot_token_cache and _bot_token_cache[1] > now + 60:
+ return _bot_token_cache[0]
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token",
+ data={
+ "grant_type": "client_credentials",
+ "client_id": self._app_id,
+ "client_secret": self._app_secret,
+ "scope": "https://api.botframework.com/.default",
+ },
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ token = data["access_token"]
+ expires_in = int(data.get("expires_in", 3600))
+ # Cache for expires_in minus 60 seconds safety margin
+ _bot_token_cache = (token, now + expires_in - 60)
+ logger.debug("Fetched new Teams bot token")
+ return token
+
+ # ==================== JWT VALIDATION ====================
+
+ async def _get_jwks(self) -> list:
+ """Fetch and cache JWKS from Bot Framework OpenID configuration."""
+ global _jwks_cache
+
+ now = time.monotonic()
+ if _jwks_cache and _jwks_cache[1] > now:
+ return _jwks_cache[0]
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ oid_resp = await client.get(
+ "https://login.botframework.com/v1/.well-known/openidconfiguration"
+ )
+ oid_resp.raise_for_status()
+ jwks_uri = oid_resp.json()["jwks_uri"]
+
+ jwks_resp = await client.get(jwks_uri)
+ jwks_resp.raise_for_status()
+ keys = jwks_resp.json()["keys"]
+
+ _jwks_cache = (keys, now + _JWKS_TTL)
+ return keys
+
+ async def validate_incoming_token(self, auth_header: str) -> bool:
+ """Validate the JWT Bearer token from an incoming Teams webhook request."""
+ try:
+ import jwt
+ from jwt.algorithms import RSAAlgorithm
+
+ if not auth_header.startswith("Bearer "):
+ return False
+ token = auth_header[7:]
+
+ keys = await self._get_jwks()
+
+ # Try each key until one validates
+ for key_data in keys:
+ try:
+ public_key = RSAAlgorithm.from_jwk(key_data)
+ jwt.decode(
+ token,
+ public_key,
+ algorithms=["RS256"],
+ audience=self._app_id,
+ )
+ return True
+ except jwt.PyJWTError:
+ continue
+
+ return False
+ except Exception as e:
+ logger.warning(f"Teams JWT validation error: {e}")
+ return False
+
+ # ==================== SEND PROACTIVE MESSAGE ====================
+
+ async def _send_activity(
+ self, teams_account: "TeamsAccount", activity: dict
+ ) -> None:
+ """Post an activity to a Teams conversation."""
+ token = await self.get_bot_token()
+ service_url = teams_account.teams_service_url.rstrip("/")
+ conversation_id = teams_account.teams_conversation_id
+
+ url = f"{service_url}/v3/conversations/{conversation_id}/activities"
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ url,
+ json=activity,
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ resp.raise_for_status()
+
+ async def send_text_message(self, teams_account: "TeamsAccount", text: str) -> None:
+ """Send a plain text message to a Teams conversation."""
+ await self._send_activity(
+ teams_account,
+ {
+ "type": "message",
+ "text": text,
+ },
+ )
+
+ async def send_approval_card(
+ self,
+ teams_account: "TeamsAccount",
+ post_title: str,
+ post_content: str,
+ approve_token: str,
+ reject_token: str,
+ company_name: str,
+ ) -> None:
+ """Send an Adaptive Card with approve/reject buttons to a Teams conversation."""
+ preview = (post_content[:300] + "…") if len(post_content) > 300 else post_content
+
+ card = {
+ "type": "AdaptiveCard",
+ "version": "1.4",
+ "body": [
+ {
+ "type": "TextBlock",
+ "text": "Post zur Freigabe",
+ "weight": "Bolder",
+ "size": "Medium",
+ },
+ {
+ "type": "TextBlock",
+ "text": f"{company_name} hat deinen Post bearbeitet:",
+ "wrap": True,
+ "color": "Accent",
+ },
+ {
+ "type": "TextBlock",
+ "text": post_title,
+ "wrap": True,
+ "weight": "Bolder",
+ },
+ {
+ "type": "TextBlock",
+ "text": preview,
+ "wrap": True,
+ "maxLines": 6,
+ "color": "Default",
+ },
+ ],
+ "actions": [
+ {
+ "type": "Action.Execute",
+ "title": "✅ Freigeben",
+ "style": "positive",
+ "data": {"action": "approve", "token": approve_token},
+ },
+ {
+ "type": "Action.Execute",
+ "title": "❌ Ablehnen",
+ "style": "destructive",
+ "data": {"action": "reject", "token": reject_token},
+ },
+ ],
+ }
+
+ await self._send_activity(
+ teams_account,
+ {
+ "type": "message",
+ "attachments": [
+ {
+ "contentType": "application/vnd.microsoft.card.adaptive",
+ "content": card,
+ }
+ ],
+ },
+ )
+
+
+# Module-level singleton — only created when Teams is enabled
+teams_service: Optional[TeamsService] = None
+if settings.teams_enabled:
+ teams_service = TeamsService(settings.microsoft_app_id, settings.microsoft_app_secret)
diff --git a/src/web/templates/user/settings.html b/src/web/templates/user/settings.html
index f3d2fda..7937d08 100644
--- a/src/web/templates/user/settings.html
+++ b/src/web/templates/user/settings.html
@@ -234,6 +234,70 @@
{% endif %}
+
+ {% if teams_enabled %}
+
+
+
+
+ Microsoft Teams verbinden
+
+
+ {% if teams_account %}
+
+
+
+
+
+
Teams verbunden
+ {% if teams_account.created_at %}
+
+ Verbunden seit {{ teams_account.created_at.strftime('%d.%m.%Y') }}
+
+ {% endif %}
+
+
+
+
+ {% else %}
+
+
+ Verbinde Microsoft Teams, um Freigabe-Anfragen direkt als Adaptive Card in deinem Teams-Chat zu erhalten und dort per Klick zu genehmigen oder abzulehnen.
+
+
+
+
So verbindest du dein Teams-Konto:
+
+
Klicke auf den Link unten – er öffnet einen Teams-Chat mit dem Bot
+
Schicke die vorausgefüllte Nachricht ab (oder bestätige mit Enter)