diff --git a/src/Support/NotificationPayloadFactory.php b/src/Support/NotificationPayloadFactory.php index 350e0b3..fba59e3 100644 --- a/src/Support/NotificationPayloadFactory.php +++ b/src/Support/NotificationPayloadFactory.php @@ -12,6 +12,10 @@ use Flarum\User\User; class NotificationPayloadFactory { + private const DEFAULT_TEAMS_APP_ID = 'd92b1be6-ea92-46b1-bcb0-c08ffdd1a61a'; + private const TEAMS_ENTITY_ID = '8ffe419f-ca44-4627-a4b2-f223da2d07aa'; + private const LANDING_PAGE_PATH = '/static/flarum-notify-v2.html'; + public function __construct( protected SettingsRepositoryInterface $settings ) {} @@ -19,53 +23,123 @@ class NotificationPayloadFactory /** * Build the payload for Microsoft Teams Activity Feed. * - * Informational notification that opens the Teams app side pane - * and marks the activity item as read. + * The activity opens the personal Teams tab. The tab loads the static + * landing page, which receives the exact Flarum discussion/post URL as a + * validated query parameter and presents it as an external link. * * @return array */ public function make(User $recipient, BlueprintInterface $blueprint): array { - // Activity Feed type (informational, but clickable via /l/entity) - $activityType = 'systemDefault'; - $forumName = trim((string) $this->settings->get('forum_title', 'sbp forum')) ?: 'sbp forum'; - // Resolve dynamic data $discussionTitle = $this->resolveDiscussionTitle($blueprint) ?? $forumName; - $previewSnippet = $this->buildPreviewText($blueprint); - $reasonText = $this->buildReasonTextEn($blueprint); + $previewSnippet = $this->buildPreviewText($blueprint); + $reasonText = $this->buildReasonTextEn($blueprint); - // Teams App + Personal Tab (THIS is what makes it clickable & read) - $teamsAppId = 'd92b1be6-ea92-46b1-bcb0-c08ffdd1a61a'; - $teamsEntityId = '8ffe419f-ca44-4627-a4b2-f223da2d07aa'; + $landingPageUrl = $this->buildLandingPageUrl($blueprint); + $teamsTabLink = $this->buildTeamsTabLink($landingPageUrl); - // Opens the app side pane and marks the activity as read - $teamsTabLink = sprintf( - 'https://teams.microsoft.com/l/entity/%s/%s?webUrl=%s', - $teamsAppId, - $teamsEntityId, - rawurlencode('https://forum.sbp.de/static/flarum-notify.html?v=2') - ); return [ 'topic' => [ 'source' => 'text', - 'value' => $discussionTitle, + 'value' => $discussionTitle, 'webUrl' => $teamsTabLink, ], - 'activityType' => $activityType, + 'activityType' => 'systemDefault', 'previewText' => [ 'content' => mb_substr($previewSnippet, 0, 150), ], 'templateParameters' => [ [ - 'name' => 'systemDefaultText', + 'name' => 'systemDefaultText', 'value' => $reasonText, ], ], ]; } + /** + * Build the URL of the static Teams landing page. + * + * The exact Flarum target is passed as an absolute URL. The landing page + * validates that it belongs to the same origin before exposing the link. + */ + protected function buildLandingPageUrl(BlueprintInterface $blueprint): string + { + $baseUrl = $this->resolveForumBaseUrl(); + + if ($baseUrl === null) { + return 'https://forum.sbp.de' . self::LANDING_PAGE_PATH; + } + + $landingPageUrl = $baseUrl . self::LANDING_PAGE_PATH; + $subjectUrl = $this->resolveSubjectUrl($blueprint); + + if ($subjectUrl === null) { + return $landingPageUrl; + } + + return $landingPageUrl . '?' . http_build_query( + ['target' => $subjectUrl], + '', + '&', + PHP_QUERY_RFC3986 + ); + } + + /** + * Build a Teams deep link to the personal app tab. + */ + protected function buildTeamsTabLink(string $webUrl): string + { + $teamsAppId = trim((string) $this->settings->get( + 'sbp-jm-msteams-webhook.teams_app_id', + self::DEFAULT_TEAMS_APP_ID + )); + + if ($teamsAppId === '') { + $teamsAppId = self::DEFAULT_TEAMS_APP_ID; + } + + return sprintf( + 'https://teams.microsoft.com/l/entity/%s/%s?webUrl=%s', + rawurlencode($teamsAppId), + rawurlencode(self::TEAMS_ENTITY_ID), + rawurlencode($webUrl) + ); + } + + /** + * Resolve and validate the configured public forum base URL. + */ + protected function resolveForumBaseUrl(): ?string + { + $baseUrl = rtrim( + trim((string) $this->settings->get( + 'sbp-jm-msteams-webhook.forum_base_url', + '' + )), + '/' + ); + + if ($baseUrl === '') { + return null; + } + + $parts = parse_url($baseUrl); + + if ( + !is_array($parts) + || ($parts['scheme'] ?? null) !== 'https' + || empty($parts['host']) + ) { + return null; + } + + return $baseUrl; + } + /** * Resolve the discussion title from the notification subject. */ @@ -85,32 +159,49 @@ class NotificationPayloadFactory } /** - * Resolve the URL to the discussion or specific post. - * (Not used for navigation in Teams, but kept for completeness.) + * Resolve the URL to the discussion or exact post. */ protected function resolveSubjectUrl(BlueprintInterface $blueprint): ?string { - $baseUrl = rtrim( - (string) $this->settings->get('sbp-jm-msteams-webhook.forum_base_url', ''), - '/' - ); + $baseUrl = $this->resolveForumBaseUrl(); - if ($baseUrl === '') { + if ($baseUrl === null) { return null; } $subject = $blueprint->getSubject(); if ($subject instanceof Discussion) { - return sprintf('%s/d/%s', $baseUrl, $subject->id); + if ($subject->id === null) { + return $baseUrl; + } + + return sprintf( + '%s/d/%s', + $baseUrl, + rawurlencode((string) $subject->id) + ); } if ($subject instanceof Post) { $discussionId = $subject->discussion_id; - $postNumber = $subject->number; + $postNumber = $subject->number; if ($discussionId !== null && $postNumber !== null) { - return sprintf('%s/d/%s/%s', $baseUrl, $discussionId, $postNumber); + return sprintf( + '%s/d/%s/%s', + $baseUrl, + rawurlencode((string) $discussionId), + rawurlencode((string) $postNumber) + ); + } + + if ($discussionId !== null) { + return sprintf( + '%s/d/%s', + $baseUrl, + rawurlencode((string) $discussionId) + ); } } @@ -123,12 +214,12 @@ class NotificationPayloadFactory protected function buildReasonTextEn(BlueprintInterface $blueprint): string { return match ($blueprint::getType()) { - 'newPost' => 'There was a new reply in a discussion you are following', - 'postMentioned' => 'You were mentioned in a post', - 'userMentioned' => 'You were mentioned', - 'postLiked' => 'Your post was liked', + 'newPost' => 'There was a new reply in a discussion you are following', + 'postMentioned' => 'You were mentioned in a post', + 'userMentioned' => 'You were mentioned', + 'postLiked' => 'Your post was liked', 'discussionRenamed' => 'A discussion was renamed', - default => 'New forum activity', + default => 'New forum activity', }; } @@ -146,7 +237,7 @@ class NotificationPayloadFactory ? (string) ($actor->display_name ?? $actor->username ?? 'Someone') : 'Someone'; - $type = $blueprint::getType(); + $type = $blueprint::getType(); $subject = $blueprint->getSubject(); $subjectTitle = null; @@ -157,12 +248,12 @@ class NotificationPayloadFactory } $text = match ($type) { - 'newPost' => sprintf('%s replied to a discussion', $actorName), - 'postMentioned' => sprintf('%s mentioned you in a post', $actorName), - 'userMentioned' => sprintf('%s mentioned you', $actorName), - 'postLiked' => sprintf('%s liked your post', $actorName), + 'newPost' => sprintf('%s replied to a discussion', $actorName), + 'postMentioned' => sprintf('%s mentioned you in a post', $actorName), + 'userMentioned' => sprintf('%s mentioned you', $actorName), + 'postLiked' => sprintf('%s liked your post', $actorName), 'discussionRenamed' => sprintf('%s renamed a discussion', $actorName), - default => sprintf('%s: new forum activity', $actorName), + default => sprintf('%s: new forum activity', $actorName), }; if ($subjectTitle) { @@ -171,4 +262,4 @@ class NotificationPayloadFactory return mb_substr($text, 0, 150); } -} \ No newline at end of file +}