implemented teams integration

This commit is contained in:
2026-02-20 16:46:44 +01:00
parent c956562722
commit d3ebffa811
12 changed files with 609 additions and 0 deletions

View 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);

View File

@@ -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

View File

@@ -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",

View File

@@ -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]:

View File

@@ -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.

View 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)

View File

@@ -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 &amp; 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.')) {

View File

@@ -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

Binary file not shown.

BIN
teams-app/color.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

42
teams-app/manifest.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B