diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 8cdaef5..a5d229f 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -21,6 +21,11 @@ class DeliverNotificationAction implements ShouldQueue private const int DISCORD_CONTENT_LIMIT = 2000; + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + public int $tries = 3; public int $timeout = 15; @@ -280,11 +285,53 @@ class DeliverNotificationAction implements ShouldQueue ->post($notificationAction->delivery_url, [ 'content' => $message, 'allowed_mentions' => [ - 'parse' => [], + 'parse' => $this->discordAllowedMentions($notificationAction), ], ]); } + /** + * @return list + */ + private function discordAllowedMentions(NotificationAction $notificationAction): array + { + $configuredAllowedMentions = data_get( + $notificationAction->channel_configuration, + 'allowed_mentions', + [], + ); + + if (! is_array($configuredAllowedMentions)) { + return []; + } + + $allowedMentions = []; + + foreach ($configuredAllowedMentions as $allowedMention) { + if (! is_string($allowedMention)) { + continue; + } + + $allowedMention = Str::lower(trim($allowedMention)); + + if ($allowedMention === '') { + continue; + } + + if (! in_array($allowedMention, self::DISCORD_ALLOWED_MENTION_TYPES, true)) { + continue; + } + + if (in_array($allowedMention, $allowedMentions, true)) { + continue; + } + + $allowedMentions[] = $allowedMention; + } + + return $allowedMentions; + } + private function sendWebhookRequest(NotificationAction $notificationAction): Response { return Http::acceptJson() diff --git a/app/Http/Requests/StoreProjectNotificationActionRequest.php b/app/Http/Requests/StoreProjectNotificationActionRequest.php index ed7f636..be63954 100644 --- a/app/Http/Requests/StoreProjectNotificationActionRequest.php +++ b/app/Http/Requests/StoreProjectNotificationActionRequest.php @@ -10,6 +10,11 @@ use Illuminate\Validation\Rule; class StoreProjectNotificationActionRequest extends FormRequest { + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + /** * Determine if the user is authorized to make this request. */ @@ -29,6 +34,9 @@ class StoreProjectNotificationActionRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'channel' => ['required', 'string', Rule::in($this->channels())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => $this->allowedMentionRules(), 'enabled' => ['required', 'boolean'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), @@ -68,4 +76,16 @@ class StoreProjectNotificationActionRequest extends FormRequest return ['nullable', 'string', 'max:2000']; } + + /** + * @return array + */ + private function allowedMentionRules(): array + { + return [ + 'nullable', + 'string', + Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES), + ]; + } } diff --git a/app/Http/Requests/UpdateProjectNotificationActionRequest.php b/app/Http/Requests/UpdateProjectNotificationActionRequest.php index 0e281bf..d2ea824 100644 --- a/app/Http/Requests/UpdateProjectNotificationActionRequest.php +++ b/app/Http/Requests/UpdateProjectNotificationActionRequest.php @@ -10,6 +10,11 @@ use Illuminate\Validation\Rule; class UpdateProjectNotificationActionRequest extends FormRequest { + /** + * @var list + */ + private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users']; + /** * Determine if the user is authorized to make this request. */ @@ -29,6 +34,9 @@ class UpdateProjectNotificationActionRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'channel' => ['required', 'string', Rule::in($this->channels())], + 'channel_configuration' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions' => ['nullable', 'array'], + 'channel_configuration.allowed_mentions.*' => $this->allowedMentionRules(), 'enabled' => ['required', 'boolean'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'message_template' => $this->messageTemplateRules(), @@ -68,4 +76,16 @@ class UpdateProjectNotificationActionRequest extends FormRequest return ['nullable', 'string', 'max:2000']; } + + /** + * @return array + */ + private function allowedMentionRules(): array + { + return [ + 'nullable', + 'string', + Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES), + ]; + } } diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 45fba9c..ceca8a0 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -44,6 +44,23 @@ const timestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', }); +const discordAllowedMentionOptions: Array<{ + value: string; + label: string; +}> = [ + { + value: 'users', + label: 'User mentions (@username)', + }, + { + value: 'roles', + label: 'Role mentions (@role)', + }, + { + value: 'everyone', + label: '@everyone and @here', + }, +]; const breadcrumbs: BreadcrumbItem[] = [ { @@ -67,6 +84,41 @@ props.notificationActions.forEach((notificationAction) => { const selectedChannel = (notificationAction: NotificationActionModel): string => editChannelState[notificationAction.id] ?? notificationAction.channel; +const notificationActionAllowedMentions = ( + notificationAction: NotificationActionModel, +): string[] => { + const allowedMentions = + notificationAction.channel_configuration['allowed_mentions']; + + if (!Array.isArray(allowedMentions)) { + return []; + } + + return allowedMentions + .filter( + (allowedMention): allowedMention is string => + typeof allowedMention === 'string', + ) + .map((allowedMention) => allowedMention.trim().toLowerCase()) + .filter((allowedMention) => allowedMention !== ''); +}; + +const allowedMentionsError = ( + errors: Record, +): string | undefined => { + const generalError = errors['channel_configuration.allowed_mentions']; + + if (generalError !== undefined) { + return generalError; + } + + const itemErrorKey = Object.keys(errors).find((key) => + key.startsWith('channel_configuration.allowed_mentions.'), + ); + + return itemErrorKey === undefined ? undefined : errors[itemErrorKey]; +}; + const notificationActionTimestamp = ( notificationAction: NotificationActionModel, ): string => { @@ -249,6 +301,52 @@ const notificationActionTimestamp = ( +
+ +
+ +
+

+ {{ + createChannelState.value === + 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+ +
+
+
+ +
+ +
+

+ {{ + selectedChannel(notificationAction) === + 'discord' + ? 'Pick which Discord mention types are allowed. Leave all unchecked to keep mentions fully disabled.' + : 'Allowed mentions are only used when the channel is Discord.' + }} +

+ +
+