From 18d56ceef92a60824a1d184162115f78720ea4df Mon Sep 17 00:00:00 2001 From: mueh Date: Wed, 15 Apr 2026 11:34:14 +0200 Subject: [PATCH] initial try --- README.md | 147 +++++++++++++++++++ composer.json | 41 ++++++ extend.php | 13 ++ locale/en.yml | 4 + src/Job/SendTeamsActivityNotificationJob.php | 25 ++++ src/Listener/MirrorAlertsToTeams.php | 70 +++++++++ src/Service/GraphTokenProvider.php | 57 +++++++ src/Service/TeamsActivityNotifier.php | 62 ++++++++ src/Support/NotificationPayloadFactory.php | 130 ++++++++++++++++ src/Support/TargetResolver.php | 50 +++++++ 10 files changed, 599 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 extend.php create mode 100644 locale/en.yml create mode 100644 src/Job/SendTeamsActivityNotificationJob.php create mode 100644 src/Listener/MirrorAlertsToTeams.php create mode 100644 src/Service/GraphTokenProvider.php create mode 100644 src/Service/TeamsActivityNotifier.php create mode 100644 src/Support/NotificationPayloadFactory.php create mode 100644 src/Support/TargetResolver.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..01b92aa --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# flarum-msteams-webhook + +Spiegelt Flarum-**Web-Benachrichtigungen** (die Glocke im Forum) in den **Microsoft Teams Activity Feed**. + +## Zielbild + +Diese erste Version registriert **keinen eigenen Teams-Kanal in der Flarum-UI**. Stattdessen spiegelt die Erweiterung exakt die Benachrichtigungen, die ein Benutzer bereits im **Web/Alert-Kanal** aktiviert hat, nach Teams. + +Das ist für Flarum 1.8.x die pragmatischste v1, weil dadurch: + +- die bestehende Flarum-Benachrichtigungslogik unverändert bleibt, +- keine Anpassung der Notification-Grid-UI notwendig ist, +- und Teams nur die Benachrichtigungen erhält, die im Forum ohnehin als Web-Alert ankommen würden. + +## Kompatibilität + +- Flarum: **^1.8** +- PHP: **^8.1** +- Getestetes Zielsystem laut Projekt-Hinweis: **Flarum 1.8.15**, **PHP 8.3.6** + +## Was die Extension macht + +1. Hängt sich per `Extend\Notification()->beforeSending(...)` in den Flarum-Notification-Flow ein. +2. Prüft für jeden Empfänger, ob die betreffende Notification im **Web-Kanal (`alert`)** aktiviert ist. +3. Löst dafür einen **Queue-Job** aus. +4. Der Queue-Job holt per **Client Credentials Flow** ein Graph-Token. +5. Anschließend wird per Microsoft Graph eine **Teams Activity Feed Notification** an den Benutzer gesendet. + +## Voraussetzungen in Microsoft 365 / Entra / Teams + +### 1) Entra App Registration + +Benötigte Konfiguration: + +- `tenant_id` +- `client_id` +- `client_secret` + +Benötigte Microsoft Graph **Application Permission**: + +- bevorzugt: `TeamsActivity.Send.User` +- alternativ: `TeamsActivity.Send` + +### 2) Teams App + +Die Ziel-Benutzer müssen die Teams-App installiert haben, für die Benachrichtigungen verschickt werden. + +Empfehlung für v1: + +- Activity Type: `systemDefault` +- Teams App ID in der Extension-Konfiguration hinterlegen + +### 3) Benutzer-Mapping + +Diese v1 unterstützt folgende Auflösungsstrategien: + +- `email` → `POST /users/{email}/teamwork/sendActivityNotification` +- `upn` → UPN aus User-Preference oder Fallback auf E-Mail +- `preference` → frei konfigurierbarer User-Preference-Key + +Wenn euer Entra-/SSO-Login bereits mit identischer E-Mail / UPN arbeitet, reicht meist `email` oder `upn`. + +## Installation + +### Composer + +```bash +composer require sbp-jm/flarum-msteams-webhook:dev-main +``` + +### Flarum Cache leeren + +```bash +php flarum cache:clear +``` + +## Konfiguration + +Da diese v1 noch kein Admin-UI mitliefert, werden die Settings direkt in der Flarum-`settings`-Tabelle gepflegt. + +### Pflicht-Settings + +```sql +REPLACE INTO settings (`key`, `value`) VALUES +('sbp-jm-msteams-webhook.enabled', '1'), +('sbp-jm-msteams-webhook.tenant_id', 'DEIN-TENANT-ID'), +('sbp-jm-msteams-webhook.client_id', 'DEINE-CLIENT-ID'), +('sbp-jm-msteams-webhook.client_secret', 'DEIN-CLIENT-SECRET'), +('sbp-jm-msteams-webhook.forum_base_url', 'https://forum.example.tld'), +('sbp-jm-msteams-webhook.user_lookup_strategy', 'email'), +('sbp-jm-msteams-webhook.activity_type', 'systemDefault'), +('sbp-jm-msteams-webhook.timeout_seconds', '15'); +``` + +### Optionale Settings + +```sql +REPLACE INTO settings (`key`, `value`) VALUES +('sbp-jm-msteams-webhook.teams_app_id', 'DEINE-TEAMS-APP-ID'), +('sbp-jm-msteams-webhook.icon_id', ''), +('sbp-jm-msteams-webhook.upn_preference_key', 'sbp-jm-msteams-webhook.upn'), +('sbp-jm-msteams-webhook.user_preference_key', 'sbp-jm-msteams-webhook.target'); +``` + +## Queue / Scheduler + +Die Extension ist für asynchrone Zustellung ausgelegt. + +Empfohlen: + +```bash +php flarum queue:work +``` + +Wenn ihr bereits den **database** Queue Driver und einen aktiven Scheduler verwendet, passt diese Erweiterung sehr gut in euer bestehendes Setup. + +## Bekannte Einschränkungen der v1 + +- Noch **kein Admin-UI** zum Setzen der Settings +- Noch **kein User-UI** für persönliches Teams-Ziel / Opt-out +- Noch **keine Teams-Spalte** im Flarum-Notification-Grid +- Activity Feed setzt voraus, dass die passende Teams-App im Zielkontext installiert ist + +## Nächste sinnvolle Schritte + +1. Admin-UI für Settings ergänzen +2. User-UI für Ziel-UPN / Opt-out ergänzen +3. Optional eine echte `Teams`-Spalte im Notification Grid ergänzen +4. Test-Command (`php flarum teams:test-user `) hinzufügen +5. Persistentes Delivery-Log ergänzen + +## Projektstruktur + +```text +extend.php +src/ + Listener/ + MirrorAlertsToTeams.php + Job/ + SendTeamsActivityNotificationJob.php + Service/ + GraphTokenProvider.php + TeamsActivityNotifier.php + Support/ + NotificationPayloadFactory.php + TargetResolver.php +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4c0ae13 --- /dev/null +++ b/composer.json @@ -0,0 +1,41 @@ +{ + "name": "sbp-jm/flarum-msteams-webhook", + "description": "Mirror Flarum web notifications to the Microsoft Teams activity feed.", + "type": "flarum-extension", + "license": "MIT", + "keywords": [ + "flarum", + "flarum-extension", + "teams", + "microsoft-teams", + "notifications", + "entra-id" + ], + "authors": [ + { + "name": "Jörg Mühlberger" + } + ], + "require": { + "php": "^8.1", + "flarum/core": "^1.8", + "guzzlehttp/guzzle": "^7.8" + }, + "autoload": { + "psr-4": { + "SbpJm\FlarumMSTeamsWebhook\": "src/" + } + }, + "extra": { + "flarum-extension": { + "title": "MS Teams Webhook", + "icon": { + "name": "fab fa-microsoft", + "backgroundColor": "#6264A7", + "color": "#ffffff" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/extend.php b/extend.php new file mode 100644 index 0000000..813d9d5 --- /dev/null +++ b/extend.php @@ -0,0 +1,13 @@ +beforeSending(MirrorAlertsToTeams::class), +]; diff --git a/locale/en.yml b/locale/en.yml new file mode 100644 index 0000000..8f9202a --- /dev/null +++ b/locale/en.yml @@ -0,0 +1,4 @@ +sbp-jm-msteams-webhook: + admin: + title: MS Teams Activity Feed + description: Mirror Flarum web notifications to Microsoft Teams. diff --git a/src/Job/SendTeamsActivityNotificationJob.php b/src/Job/SendTeamsActivityNotificationJob.php new file mode 100644 index 0000000..adf1e7d --- /dev/null +++ b/src/Job/SendTeamsActivityNotificationJob.php @@ -0,0 +1,25 @@ +notify($this->user, $this->blueprint); + } +} diff --git a/src/Listener/MirrorAlertsToTeams.php b/src/Listener/MirrorAlertsToTeams.php new file mode 100644 index 0000000..011aab0 --- /dev/null +++ b/src/Listener/MirrorAlertsToTeams.php @@ -0,0 +1,70 @@ +isEnabled()) { + return $newRecipients; + } + + foreach ($newRecipients as $user) { + if (!$user instanceof User) { + continue; + } + + if (!$this->shouldMirrorForUser($user, $blueprint)) { + continue; + } + + if ($this->targetResolver->resolve($user) === null) { + continue; + } + + $this->queue->push(new SendTeamsActivityNotificationJob($blueprint, $user)); + } + + return $newRecipients; + } + + protected function isEnabled(): bool + { + return filter_var($this->settings->get('sbp-jm-msteams-webhook.enabled', '0'), FILTER_VALIDATE_BOOLEAN); + } + + protected function shouldMirrorForUser(User $user, BlueprintInterface $blueprint): bool + { + $webPreferenceKey = User::getNotificationPreferenceKey($blueprint::getType(), 'alert'); + $webEnabled = (bool) $user->getPreference($webPreferenceKey, false); + + if (!$webEnabled) { + return false; + } + + $globalOptOutKey = 'sbp-jm-msteams-webhook.user_disabled'; + + return !(bool) $user->getPreference($globalOptOutKey, false); + } +} diff --git a/src/Service/GraphTokenProvider.php b/src/Service/GraphTokenProvider.php new file mode 100644 index 0000000..9bed56c --- /dev/null +++ b/src/Service/GraphTokenProvider.php @@ -0,0 +1,57 @@ +accessToken !== null && time() < ($this->expiresAt - 60)) { + return $this->accessToken; + } + + $tenantId = trim((string) $this->settings->get('sbp-jm-msteams-webhook.tenant_id', '')); + $clientId = trim((string) $this->settings->get('sbp-jm-msteams-webhook.client_id', '')); + $clientSecret = (string) $this->settings->get('sbp-jm-msteams-webhook.client_secret', ''); + + if ($tenantId === '' || $clientId === '' || $clientSecret === '') { + throw new RuntimeException('Missing Microsoft Graph credentials in Flarum settings.'); + } + + $response = $this->http->request('POST', sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', rawurlencode($tenantId)), [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'scope' => 'https://graph.microsoft.com/.default', + ], + 'timeout' => (int) $this->settings->get('sbp-jm-msteams-webhook.timeout_seconds', '15'), + ]); + + $payload = json_decode((string) $response->getBody(), true); + + if (!is_array($payload) || empty($payload['access_token'])) { + throw new RuntimeException('Microsoft Graph token response did not contain an access token.'); + } + + $this->accessToken = (string) $payload['access_token']; + $this->expiresAt = time() + (int) ($payload['expires_in'] ?? 3600); + + return $this->accessToken; + } +} diff --git a/src/Service/TeamsActivityNotifier.php b/src/Service/TeamsActivityNotifier.php new file mode 100644 index 0000000..8d75502 --- /dev/null +++ b/src/Service/TeamsActivityNotifier.php @@ -0,0 +1,62 @@ +targetResolver->resolve($user); + + if ($target === null) { + return; + } + + $payload = $this->payloadFactory->make($user, $blueprint); + $token = $this->tokenProvider->getAccessToken(); + + $response = $this->http->request( + 'POST', + sprintf('https://graph.microsoft.com/v1.0/users/%s/teamwork/sendActivityNotification', rawurlencode($target)), + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + 'timeout' => (int) $this->settings->get('sbp-jm-msteams-webhook.timeout_seconds', '15'), + 'http_errors' => false, + ] + ); + + $status = $response->getStatusCode(); + + if ($status < 200 || $status >= 300) { + throw new RuntimeException(sprintf( + 'Teams activity notification failed for user %s with status %s: %s', + $user->id, + $status, + (string) $response->getBody() + )); + } + } +} diff --git a/src/Support/NotificationPayloadFactory.php b/src/Support/NotificationPayloadFactory.php new file mode 100644 index 0000000..7d79e58 --- /dev/null +++ b/src/Support/NotificationPayloadFactory.php @@ -0,0 +1,130 @@ + + */ + public function make(User $recipient, BlueprintInterface $blueprint): array + { + $activityType = trim((string) $this->settings->get('sbp-jm-msteams-webhook.activity_type', 'systemDefault')); + $forumName = trim((string) $this->settings->get('forum_title', 'Flarum')) ?: 'Flarum'; + $topicUrl = $this->resolveSubjectUrl($blueprint) ?? rtrim((string) $this->settings->get('sbp-jm-msteams-webhook.forum_base_url', ''), '/'); + $previewText = $this->buildPreviewText($blueprint); + $teamsAppId = trim((string) $this->settings->get('sbp-jm-msteams-webhook.teams_app_id', '')); + $iconId = trim((string) $this->settings->get('sbp-jm-msteams-webhook.icon_id', '')); + + $payload = [ + 'topic' => [ + 'source' => 'text', + 'value' => $forumName, + 'webUrl' => $topicUrl, + ], + 'activityType' => $activityType === '' ? 'systemDefault' : $activityType, + 'previewText' => [ + 'content' => $previewText, + ], + 'chainId' => $this->buildChainId($recipient, $blueprint), + ]; + + if ($teamsAppId !== '') { + $payload['teamsAppId'] = $teamsAppId; + } + + if ($iconId !== '') { + $payload['iconId'] = $iconId; + } + + if (($payload['activityType'] ?? 'systemDefault') !== 'systemDefault') { + $payload['templateParameters'] = [ + [ + 'name' => 'notificationText', + 'value' => $previewText, + ], + ]; + } + + return $payload; + } + + protected function buildPreviewText(BlueprintInterface $blueprint): string + { + $actor = $blueprint->getFromUser(); + $actorName = $actor instanceof User ? (string) ($actor->display_name ?? $actor->username ?? 'Jemand') : 'Jemand'; + $type = $blueprint::getType(); + $subject = $blueprint->getSubject(); + + $subjectTitle = null; + if ($subject instanceof Discussion) { + $subjectTitle = (string) $subject->title; + } elseif ($subject instanceof Post && $subject->discussion) { + $subjectTitle = (string) $subject->discussion->title; + } + + $text = match ($type) { + 'newPost' => sprintf('%s hat in einer beobachteten Diskussion geantwortet', $actorName), + 'postMentioned' => sprintf('%s hat dich in einem Beitrag erwähnt', $actorName), + 'userMentioned' => sprintf('%s hat dich erwähnt', $actorName), + 'postLiked' => sprintf('%s hat einen Beitrag von dir geliked', $actorName), + 'discussionRenamed' => sprintf('%s hat eine Diskussion umbenannt', $actorName), + default => sprintf('%s: neue Flarum-Benachrichtigung (%s)', $actorName, $type), + }; + + if ($subjectTitle) { + $text .= ' – ' . $subjectTitle; + } + + return mb_substr($text, 0, 150); + } + + protected function resolveSubjectUrl(BlueprintInterface $blueprint): ?string + { + $baseUrl = rtrim((string) $this->settings->get('sbp-jm-msteams-webhook.forum_base_url', ''), '/'); + if ($baseUrl === '') { + return null; + } + + $subject = $blueprint->getSubject(); + + if ($subject instanceof Discussion) { + return sprintf('%s/d/%s', $baseUrl, $subject->id); + } + + if ($subject instanceof Post) { + $discussionId = $subject->discussion_id; + $postNumber = $subject->number; + + if ($discussionId !== null && $postNumber !== null) { + return sprintf('%s/d/%s/%s', $baseUrl, $discussionId, $postNumber); + } + } + + return $baseUrl; + } + + protected function buildChainId(User $recipient, BlueprintInterface $blueprint): int + { + $subject = $blueprint->getSubject(); + $subjectId = is_object($subject) && isset($subject->id) ? (string) $subject->id : '0'; + + return abs(crc32(implode('|', [ + (string) $recipient->id, + $blueprint::getType(), + $subjectId, + ]))); + } +} diff --git a/src/Support/TargetResolver.php b/src/Support/TargetResolver.php new file mode 100644 index 0000000..e48906b --- /dev/null +++ b/src/Support/TargetResolver.php @@ -0,0 +1,50 @@ +settings->get('sbp-jm-msteams-webhook.user_lookup_strategy', 'email')); + + return match ($strategy) { + 'email' => $this->clean($user->email), + 'upn' => $this->resolveUpn($user), + 'preference' => $this->clean($user->getPreference((string) $this->settings->get('sbp-jm-msteams-webhook.user_preference_key', 'sbp-jm-msteams-webhook.target'))), + default => $this->clean($user->email), + }; + } + + protected function resolveUpn(User $user): ?string + { + $preferenceKey = (string) $this->settings->get('sbp-jm-msteams-webhook.upn_preference_key', 'sbp-jm-msteams-webhook.upn'); + $upn = $this->clean($user->getPreference($preferenceKey)); + + if ($upn !== null) { + return $upn; + } + + return $this->clean($user->email); + } + + protected function clean(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } +}