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
|
uvicorn==0.32.0
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
python-multipart==0.0.9
|
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_secret: str = "" # Random string to validate incoming webhooks
|
||||||
telegram_webhook_url: str = "" # Base URL of the app, e.g. https://app.example.com
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -874,6 +874,52 @@ class DatabaseClient:
|
|||||||
logger.info(f"Deleted Telegram account for user: {user_id}")
|
logger.info(f"Deleted Telegram account for user: {user_id}")
|
||||||
return True
|
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 ====================
|
# ==================== USERS ====================
|
||||||
|
|
||||||
async def get_user(self, user_id: UUID) -> Optional[User]:
|
async def get_user(self, user_id: UUID) -> Optional[User]:
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ class TelegramAccount(DBModel):
|
|||||||
last_error_at: Optional[datetime] = None
|
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):
|
class User(DBModel):
|
||||||
"""User model - combines auth.users data with profile data.
|
"""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>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if session and session.account_type == 'employee' and session.company_id and company_permissions %}
|
||||||
<!-- Company Permissions -->
|
<!-- Company Permissions -->
|
||||||
<div class="card-bg rounded-xl border border-gray-700 p-6 mb-6">
|
<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
|
// LinkedIn disconnect
|
||||||
async function disconnectLinkedIn() {
|
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.')) {
|
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:
|
except Exception as tg_err:
|
||||||
logger.warning(f"Telegram notification failed: {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:
|
else:
|
||||||
# Employee/owner moving their own post to "approved": no email/telegram notification
|
# Employee/owner moving their own post to "approved": no email/telegram notification
|
||||||
pass
|
pass
|
||||||
@@ -2507,6 +2523,11 @@ async def settings_page(request: Request):
|
|||||||
if settings.telegram_enabled:
|
if settings.telegram_enabled:
|
||||||
telegram_account = await db.get_telegram_account(user_id)
|
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
|
# Get company permissions if user is an employee with a company
|
||||||
company_permissions = None
|
company_permissions = None
|
||||||
company = None
|
company = None
|
||||||
@@ -2523,6 +2544,9 @@ async def settings_page(request: Request):
|
|||||||
"linkedin_account": linkedin_account,
|
"linkedin_account": linkedin_account,
|
||||||
"telegram_enabled": settings.telegram_enabled,
|
"telegram_enabled": settings.telegram_enabled,
|
||||||
"telegram_account": telegram_account,
|
"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_permissions": company_permissions,
|
||||||
"company": company,
|
"company": company,
|
||||||
})
|
})
|
||||||
@@ -5595,3 +5619,152 @@ if settings.telegram_enabled:
|
|||||||
|
|
||||||
await db.delete_telegram_account(UUID(session.user_id))
|
await db.delete_telegram_account(UUID(session.user_id))
|
||||||
return JSONResponse({"success": True})
|
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