implemented teams integration
This commit is contained in:
17
migrations/add_teams_accounts.sql
Normal file
17
migrations/add_teams_accounts.sql
Normal file
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
217
src/services/teams_service.py
Normal file
217
src/services/teams_service.py
Normal file
@@ -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)
|
||||
@@ -234,6 +234,70 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Microsoft Teams Bot Connection -->
|
||||
{% if teams_enabled %}
|
||||
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<!-- Teams icon (simplified) -->
|
||||
<svg class="w-5 h-5 text-[#6264A7]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.192 6.5H13.5a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h5.692A2.308 2.308 0 0 0 21.5 12.192V8.808A2.308 2.308 0 0 0 19.192 6.5ZM17 11.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm-4.5 5H6.808A2.308 2.308 0 0 1 4.5 14.192V9.808A2.308 2.308 0 0 1 6.808 7.5H9.5a.5.5 0 0 1 .5.5v5.5h2.5a.5.5 0 0 1 0 1ZM9 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
|
||||
</svg>
|
||||
Microsoft Teams verbinden
|
||||
</h2>
|
||||
|
||||
{% if teams_account %}
|
||||
<!-- Connected State -->
|
||||
<div class="bg-green-900/20 border border-green-600 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-white font-medium">Teams verbunden</p>
|
||||
{% if teams_account.created_at %}
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Verbunden seit {{ teams_account.created_at.strftime('%d.%m.%Y') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="disconnectTeams()"
|
||||
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Verbindung trennen
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Not Connected State -->
|
||||
<p class="text-gray-400 mb-4">
|
||||
Verbinde Microsoft Teams, um Freigabe-Anfragen direkt als Adaptive Card in deinem Teams-Chat zu erhalten und dort per Klick zu genehmigen oder abzulehnen.
|
||||
</p>
|
||||
<button onclick="connectTeams()"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-[#6264A7] hover:bg-[#4f5195] text-white rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19.192 6.5H13.5a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h5.692A2.308 2.308 0 0 0 21.5 12.192V8.808A2.308 2.308 0 0 0 19.192 6.5ZM17 11.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm-4.5 5H6.808A2.308 2.308 0 0 1 4.5 14.192V9.808A2.308 2.308 0 0 1 6.808 7.5H9.5a.5.5 0 0 1 .5.5v5.5h2.5a.5.5 0 0 1 0 1ZM9 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
|
||||
</svg>
|
||||
Mit Teams verbinden
|
||||
</button>
|
||||
<div id="teamsLinkBox" class="hidden mt-4 p-4 bg-brand-bg-light rounded-lg border border-gray-600">
|
||||
<p class="text-sm text-gray-300 mb-2 font-medium">So verbindest du dein Teams-Konto:</p>
|
||||
<ol class="text-sm text-gray-400 space-y-1 list-decimal list-inside mb-3">
|
||||
<li>Klicke auf den Link unten – er öffnet einen Teams-Chat mit dem Bot</li>
|
||||
<li>Schicke die vorausgefüllte Nachricht ab (oder bestätige mit Enter)</li>
|
||||
<li>Du erhältst eine Bestätigung direkt im Teams-Chat</li>
|
||||
</ol>
|
||||
<a id="teamsDeepLink" href="#" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-[#6264A7] hover:bg-[#4f5195] text-white text-sm rounded-lg transition-colors">
|
||||
Teams öffnen & Bot starten
|
||||
</a>
|
||||
<p class="text-xs text-gray-500 mt-2">Der Link ist 10 Minuten gültig.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session and session.account_type == 'employee' and session.company_id and company_permissions %}
|
||||
<!-- Company Permissions -->
|
||||
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
|
||||
@@ -499,6 +563,35 @@ async function disconnectTelegram() {
|
||||
}
|
||||
}
|
||||
|
||||
// Teams connect
|
||||
async function connectTeams() {
|
||||
try {
|
||||
const res = await fetch('/settings/teams/start');
|
||||
if (!res.ok) throw new Error('Fehler beim Generieren des Links');
|
||||
const data = await res.json();
|
||||
const linkEl = document.getElementById('teamsDeepLink');
|
||||
linkEl.href = data.deep_link;
|
||||
document.getElementById('teamsLinkBox').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error connecting Teams:', error);
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Teams disconnect
|
||||
async function disconnectTeams() {
|
||||
if (!confirm('Teams-Verbindung wirklich trennen?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/api/settings/teams/disconnect', { method: 'POST' });
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Teams:', error);
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// LinkedIn disconnect
|
||||
async function disconnectLinkedIn() {
|
||||
if (!confirm('LinkedIn-Verbindung wirklich trennen?\n\nPosts werden dann nicht mehr automatisch veröffentlicht und du erhältst wieder Email-Benachrichtigungen.')) {
|
||||
|
||||
@@ -2339,6 +2339,22 @@ async def update_post_status(
|
||||
)
|
||||
except Exception as tg_err:
|
||||
logger.warning(f"Telegram notification failed: {tg_err}")
|
||||
# Send Teams Adaptive Card with approve/reject buttons
|
||||
if settings.teams_enabled:
|
||||
try:
|
||||
from src.services.teams_service import teams_service as _ts
|
||||
teams_account = await db.get_teams_account(post.user_id)
|
||||
if _ts and teams_account and teams_account.is_active:
|
||||
await _ts.send_approval_card(
|
||||
teams_account=teams_account,
|
||||
post_title=post.topic_title or "Untitled Post",
|
||||
post_content=post.post_content or "",
|
||||
approve_token=approve_token,
|
||||
reject_token=reject_token,
|
||||
company_name=company_name,
|
||||
)
|
||||
except Exception as teams_err:
|
||||
logger.warning(f"Teams notification failed: {teams_err}")
|
||||
else:
|
||||
# Employee/owner moving their own post to "approved": no email/telegram notification
|
||||
pass
|
||||
@@ -2507,6 +2523,11 @@ async def settings_page(request: Request):
|
||||
if settings.telegram_enabled:
|
||||
telegram_account = await db.get_telegram_account(user_id)
|
||||
|
||||
# Get Teams account if feature is enabled
|
||||
teams_account = None
|
||||
if settings.teams_enabled:
|
||||
teams_account = await db.get_teams_account(user_id)
|
||||
|
||||
# Get company permissions if user is an employee with a company
|
||||
company_permissions = None
|
||||
company = None
|
||||
@@ -2523,6 +2544,9 @@ async def settings_page(request: Request):
|
||||
"linkedin_account": linkedin_account,
|
||||
"telegram_enabled": settings.telegram_enabled,
|
||||
"telegram_account": telegram_account,
|
||||
"teams_enabled": settings.teams_enabled,
|
||||
"teams_account": teams_account,
|
||||
"microsoft_app_id": settings.microsoft_app_id,
|
||||
"company_permissions": company_permissions,
|
||||
"company": company,
|
||||
})
|
||||
@@ -5595,3 +5619,152 @@ if settings.telegram_enabled:
|
||||
|
||||
await db.delete_telegram_account(UUID(session.user_id))
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
|
||||
# ==================== MICROSOFT TEAMS BOT ====================
|
||||
|
||||
if settings.teams_enabled:
|
||||
import secrets as _secrets_teams
|
||||
from src.services.teams_service import teams_service as _teams_service
|
||||
|
||||
@user_router.get("/settings/teams/start")
|
||||
async def teams_start_link(request: Request):
|
||||
"""Generate a one-time Teams linking token and return the deep link."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
from src.services.redis_client import get_redis
|
||||
token = _secrets_teams.token_urlsafe(32)
|
||||
try:
|
||||
redis = await get_redis()
|
||||
await redis.setex(f"teams_link:{token}", 600, session.user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store teams link token: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to generate link")
|
||||
|
||||
deep_link = (
|
||||
f"https://teams.microsoft.com/l/chat/0/0"
|
||||
f"?users=28:{settings.microsoft_app_id}&message=link:{token}"
|
||||
)
|
||||
return JSONResponse({"deep_link": deep_link, "token": token})
|
||||
|
||||
async def _handle_teams_link(activity: dict, link_token: str) -> None:
|
||||
"""Handle account linking when user sends 'link:{token}' to the bot."""
|
||||
from src.services.redis_client import get_redis
|
||||
from src.database.models import TeamsAccount
|
||||
|
||||
try:
|
||||
redis = await get_redis()
|
||||
link_key = f"teams_link:{link_token}"
|
||||
raw_user_id = await redis.get(link_key)
|
||||
if not raw_user_id:
|
||||
logger.warning(f"Teams link token not found or expired: {link_token[:8]}…")
|
||||
if _teams_service:
|
||||
conv_id = activity.get("conversation", {}).get("id", "")
|
||||
service_url = activity.get("serviceUrl", "")
|
||||
if conv_id and service_url:
|
||||
from src.database.models import TeamsAccount as _TA
|
||||
dummy = _TA(
|
||||
user_id=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
teams_service_url=service_url,
|
||||
teams_conversation_id=conv_id,
|
||||
)
|
||||
await _teams_service.send_text_message(
|
||||
dummy,
|
||||
"❌ Dieser Link ist ungültig oder abgelaufen. Bitte erstelle einen neuen Link in der App.",
|
||||
)
|
||||
return
|
||||
|
||||
user_id_str = raw_user_id.decode() if isinstance(raw_user_id, bytes) else raw_user_id
|
||||
await redis.delete(link_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to look up teams link token: {e}")
|
||||
return
|
||||
|
||||
# Extract conversation reference from incoming activity
|
||||
service_url = activity.get("serviceUrl", "")
|
||||
conversation_id = activity.get("conversation", {}).get("id", "")
|
||||
teams_user_id = activity.get("from", {}).get("id", "")
|
||||
tenant_id = activity.get("channelData", {}).get("tenant", {}).get("id")
|
||||
|
||||
if not service_url or not conversation_id:
|
||||
logger.error("Teams link activity missing serviceUrl or conversation.id")
|
||||
return
|
||||
|
||||
account = TeamsAccount(
|
||||
user_id=UUID(user_id_str),
|
||||
teams_user_id=teams_user_id or None,
|
||||
teams_tenant_id=tenant_id,
|
||||
teams_service_url=service_url,
|
||||
teams_conversation_id=conversation_id,
|
||||
is_active=True,
|
||||
)
|
||||
await db.save_teams_account(account)
|
||||
|
||||
if _teams_service:
|
||||
await _teams_service.send_text_message(
|
||||
account,
|
||||
"✅ Dein Teams-Konto ist jetzt verbunden! Du erhältst ab sofort Freigabe-Anfragen direkt hier.",
|
||||
)
|
||||
|
||||
async def _handle_teams_approval(activity: dict, action: str, token: str) -> None:
|
||||
"""Handle approve/reject invoke from an Adaptive Card button."""
|
||||
from src.services.email_service import validate_token, mark_all_post_tokens_used
|
||||
|
||||
token_data = await validate_token(token)
|
||||
if not token_data:
|
||||
logger.warning(f"Teams approval: invalid or used token {token[:8]}…")
|
||||
return
|
||||
|
||||
post_id = UUID(token_data["post_id"])
|
||||
new_status = "ready" if action == "approve" else "draft"
|
||||
await db.update_generated_post(post_id, {"status": new_status})
|
||||
await db.mark_all_post_tokens_used(post_id)
|
||||
|
||||
logger.info(f"Teams approval handled: post={post_id} action={action} status={new_status}")
|
||||
|
||||
@user_router.post("/api/teams/webhook")
|
||||
async def teams_webhook(request: Request):
|
||||
"""Receive Microsoft Teams bot activities."""
|
||||
# Validate incoming JWT
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if _teams_service and not await _teams_service.validate_incoming_token(auth_header):
|
||||
raise HTTPException(status_code=403, detail="Invalid Teams JWT")
|
||||
|
||||
try:
|
||||
activity = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||
|
||||
activity_type = activity.get("type", "")
|
||||
|
||||
if activity_type == "message":
|
||||
text = (activity.get("text") or "").strip()
|
||||
if text.startswith("link:"):
|
||||
link_token = text[5:].strip()
|
||||
await _handle_teams_link(activity, link_token)
|
||||
|
||||
elif activity_type == "invoke" and activity.get("name") == "adaptiveCard/action":
|
||||
value = activity.get("value", {})
|
||||
action = value.get("action", {})
|
||||
action_data = action.get("data", {}) if isinstance(action, dict) else {}
|
||||
act = action_data.get("action", "")
|
||||
token = action_data.get("token", "")
|
||||
if act in ("approve", "reject") and token:
|
||||
await _handle_teams_approval(activity, act, token)
|
||||
# Teams requires a synchronous 200 with statusCode for invoke
|
||||
return JSONResponse({"statusCode": 200})
|
||||
|
||||
# conversationUpdate and other types — ignore
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@user_router.post("/api/settings/teams/disconnect")
|
||||
async def teams_disconnect(request: Request):
|
||||
"""Disconnect Teams account."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
await db.delete_teams_account(UUID(session.user_id))
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
BIN
teams-app.zip
Normal file
BIN
teams-app.zip
Normal file
Binary file not shown.
BIN
teams-app/color.png
Normal file
BIN
teams-app/color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 592 B |
42
teams-app/manifest.json
Normal file
42
teams-app/manifest.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.16",
|
||||
"version": "1.0.0",
|
||||
"id": "8fcf27ef-c9da-411a-a429-780bc082e596",
|
||||
"developer": {
|
||||
"name": "Onyva",
|
||||
"websiteUrl": "https://linkedin.onyva.dev",
|
||||
"privacyUrl": "https://linkedin.onyva.dev/privacy",
|
||||
"termsOfUseUrl": "https://linkedin.onyva.dev/terms"
|
||||
},
|
||||
"name": {
|
||||
"short": "LinkedIn Workflow"
|
||||
},
|
||||
"description": {
|
||||
"short": "Post-Freigaben direkt in Teams",
|
||||
"full": "Erhalte LinkedIn Post-Freigabeanfragen direkt in Microsoft Teams und genehmige oder lehne sie per Klick ab."
|
||||
},
|
||||
"icons": {
|
||||
"color": "color.png",
|
||||
"outline": "outline.png"
|
||||
},
|
||||
"accentColor": "#6264A7",
|
||||
"bots": [
|
||||
{
|
||||
"botId": "8fcf27ef-c9da-411a-a429-780bc082e596",
|
||||
"scopes": [
|
||||
"personal"
|
||||
],
|
||||
"isNotificationOnly": false,
|
||||
"supportsCalling": false,
|
||||
"supportsVideo": false
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
"identity",
|
||||
"messageTeamMembers"
|
||||
],
|
||||
"validDomains": [
|
||||
"linkedin.onyva.dev"
|
||||
]
|
||||
}
|
||||
BIN
teams-app/outline.png
Normal file
BIN
teams-app/outline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Reference in New Issue
Block a user