From d3ebffa811bc9a648589b059e3479242e909b2bb Mon Sep 17 00:00:00 2001 From: Ruben Fischer Date: Fri, 20 Feb 2026 16:46:44 +0100 Subject: [PATCH] implemented teams integration --- migrations/add_teams_accounts.sql | 17 +++ requirements.txt | 3 + src/config.py | 5 + src/database/client.py | 46 ++++++ src/database/models.py | 13 ++ src/services/teams_service.py | 217 +++++++++++++++++++++++++++ src/web/templates/user/settings.html | 93 ++++++++++++ src/web/user/routes.py | 173 +++++++++++++++++++++ teams-app.zip | Bin 0 -> 1018 bytes teams-app/color.png | Bin 0 -> 592 bytes teams-app/manifest.json | 42 ++++++ teams-app/outline.png | Bin 0 -> 98 bytes 12 files changed, 609 insertions(+) create mode 100644 migrations/add_teams_accounts.sql create mode 100644 src/services/teams_service.py create mode 100644 teams-app.zip create mode 100644 teams-app/color.png create mode 100644 teams-app/manifest.json create mode 100644 teams-app/outline.png 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. +

+ + + {% endif %} +
+ {% endif %} + {% if session and session.account_type == 'employee' and session.company_id and company_permissions %}
@@ -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.')) { diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 2f2d479..5007512 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -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}) diff --git a/teams-app.zip b/teams-app.zip new file mode 100644 index 0000000000000000000000000000000000000000..401312339d0166df58a48eecdc8899d6a874ae7f GIT binary patch literal 1018 zcmWIWW@Zs#U|`^2P;Cu~>6GLT{l>_^;LgIpzzY=3P0Y(oOD!(Z%PP*#n;Lw&@3Mi& z-S66K4op(X4PEy3%>{-_9BrEt+E|kwT9}_*TIaGa&+Y1p|9!I0FFGy|w0?c3_+0I; z%wrLsX1VZosbAMwIXC--)p_p>$60JS%K{>tmle%4&|;q}r}^L2ZF!)}PUdwQ`QkSp zY3-Ljai3|1tC!e|ld~sy7WRrxsAlkf;W*#$Mbd`7EYEM+EQ(-D2=2_<*`_6Wd(!gN zN1K+{Y`wGW_1QfFpU(>2%d4Kgu}HuAQ$xE+xPVXDuPdMWo*s19;E=r&U3qi6OKpQt zX&RIH=QQU18*da9R!yA${fOi916sF2zkexOwdb)~T=~1Gm#^RLUT`ZyoX4*GQ1sb- z($y=LRL&7mov`7?4vS;L#RP+B#YE0diWcOI3^9@lCCQn%ug%v|||?brU@bahwOW63Btei0w5yFTpwwT~`bkDdDD=LVkB&^l=`52UZp{31=tZ0N{cHc`e`MD= z$a7JzV&2!4PK;cdf0nH@`ak)P%Z1+QD;rL%H_&;_7xFOM&#ifyx6Xa(qaQCl)ZeeH zk@oz407?SM*%!X69GEZym>3v1fpl_yPJWSIL0z4vfrc3M& zGn#ol*zTT8OixG<`d(7VvvS7gZ<`h>G&db+AI!PT?m1~dekvqmSGlwhPowa>q!qyExrd~-mWL0m_Y(04S>iN{U!A`;q zsIF@>sl5;gbX5`%a|3aHX-Q6IUMhC?aVeZR$fnZ0deuG#O<#ZU;}g0p^UrG7icphlL#~JtOK+j3>tvh z2`wX`Yef%Kh(-p621Z3-FvEiwT^qV@5!&_xwITUEz?+o~B*_eftAVs7Gl&NO(i57U literal 0 HcmV?d00001 diff --git a/teams-app/color.png b/teams-app/color.png new file mode 100644 index 0000000000000000000000000000000000000000..10a8857488250e724fc86ad70c024aa60eaee6f6 GIT binary patch literal 592 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;U=sIqaSW-L^Y*f#AcKOyfeqJB zJ>;FK(7aG`Q~Ap^wR$0E-!uF;$*@GS!Gw828p9=5JYD@<);T3K0RX_ktmps$ literal 0 HcmV?d00001 diff --git a/teams-app/manifest.json b/teams-app/manifest.json new file mode 100644 index 0000000..92da8a0 --- /dev/null +++ b/teams-app/manifest.json @@ -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" + ] +} \ No newline at end of file diff --git a/teams-app/outline.png b/teams-app/outline.png new file mode 100644 index 0000000000000000000000000000000000000000..234f5d02bbaa710dfae98fb9216f221f86616ff1 GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzO-~ockcv5P&nYqjc}xob=U