Add Discord allowed mentions configuration

This commit is contained in:
2026-04-24 17:01:34 +00:00
parent 9958db10b9
commit 55db0ac642
6 changed files with 300 additions and 1 deletions
@@ -21,6 +21,11 @@ class DeliverNotificationAction implements ShouldQueue
private const int DISCORD_CONTENT_LIMIT = 2000; private const int DISCORD_CONTENT_LIMIT = 2000;
/**
* @var list<string>
*/
private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users'];
public int $tries = 3; public int $tries = 3;
public int $timeout = 15; public int $timeout = 15;
@@ -280,11 +285,53 @@ class DeliverNotificationAction implements ShouldQueue
->post($notificationAction->delivery_url, [ ->post($notificationAction->delivery_url, [
'content' => $message, 'content' => $message,
'allowed_mentions' => [ 'allowed_mentions' => [
'parse' => [], 'parse' => $this->discordAllowedMentions($notificationAction),
], ],
]); ]);
} }
/**
* @return list<string>
*/
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 private function sendWebhookRequest(NotificationAction $notificationAction): Response
{ {
return Http::acceptJson() return Http::acceptJson()
@@ -10,6 +10,11 @@ use Illuminate\Validation\Rule;
class StoreProjectNotificationActionRequest extends FormRequest class StoreProjectNotificationActionRequest extends FormRequest
{ {
/**
* @var list<string>
*/
private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users'];
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
@@ -29,6 +34,9 @@ class StoreProjectNotificationActionRequest extends FormRequest
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())],
'channel' => ['required', 'string', Rule::in($this->channels())], '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'], 'enabled' => ['required', 'boolean'],
'delivery_url' => ['required', 'string', 'max:2048', 'url'], 'delivery_url' => ['required', 'string', 'max:2048', 'url'],
'message_template' => $this->messageTemplateRules(), 'message_template' => $this->messageTemplateRules(),
@@ -68,4 +76,16 @@ class StoreProjectNotificationActionRequest extends FormRequest
return ['nullable', 'string', 'max:2000']; return ['nullable', 'string', 'max:2000'];
} }
/**
* @return array<int, ValidationRule|string>
*/
private function allowedMentionRules(): array
{
return [
'nullable',
'string',
Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES),
];
}
} }
@@ -10,6 +10,11 @@ use Illuminate\Validation\Rule;
class UpdateProjectNotificationActionRequest extends FormRequest class UpdateProjectNotificationActionRequest extends FormRequest
{ {
/**
* @var list<string>
*/
private const array DISCORD_ALLOWED_MENTION_TYPES = ['everyone', 'roles', 'users'];
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
@@ -29,6 +34,9 @@ class UpdateProjectNotificationActionRequest extends FormRequest
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'event_type' => ['required', 'string', Rule::in($this->eventTypes())], 'event_type' => ['required', 'string', Rule::in($this->eventTypes())],
'channel' => ['required', 'string', Rule::in($this->channels())], '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'], 'enabled' => ['required', 'boolean'],
'delivery_url' => ['nullable', 'string', 'max:2048', 'url'], 'delivery_url' => ['nullable', 'string', 'max:2048', 'url'],
'message_template' => $this->messageTemplateRules(), 'message_template' => $this->messageTemplateRules(),
@@ -68,4 +76,16 @@ class UpdateProjectNotificationActionRequest extends FormRequest
return ['nullable', 'string', 'max:2000']; return ['nullable', 'string', 'max:2000'];
} }
/**
* @return array<int, ValidationRule|string>
*/
private function allowedMentionRules(): array
{
return [
'nullable',
'string',
Rule::in(self::DISCORD_ALLOWED_MENTION_TYPES),
];
}
} }
@@ -44,6 +44,23 @@ const timestampFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', 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[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
@@ -67,6 +84,41 @@ props.notificationActions.forEach((notificationAction) => {
const selectedChannel = (notificationAction: NotificationActionModel): string => const selectedChannel = (notificationAction: NotificationActionModel): string =>
editChannelState[notificationAction.id] ?? notificationAction.channel; 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, string>,
): 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 = ( const notificationActionTimestamp = (
notificationAction: NotificationActionModel, notificationAction: NotificationActionModel,
): string => { ): string => {
@@ -249,6 +301,52 @@ const notificationActionTimestamp = (
<InputError :message="errors.delivery_url" /> <InputError :message="errors.delivery_url" />
</div> </div>
<div class="grid gap-2">
<Label>Allowed mentions</Label>
<div
:class="[
'grid gap-2',
createChannelState.value !==
'discord'
? 'hidden'
: '',
]"
>
<label
v-for="allowedMentionOption in discordAllowedMentionOptions"
:key="`create-allowed-mention-${allowedMentionOption.value}`"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="channel_configuration[allowed_mentions][]"
:value="
allowedMentionOption.value
"
:disabled="
createChannelState.value !==
'discord'
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{ allowedMentionOption.label }}
</span>
</label>
</div>
<p class="text-muted-foreground text-sm">
{{
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.'
}}
</p>
<InputError
:message="allowedMentionsError(errors)"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label <Label
for="create-notification-message-template" for="create-notification-message-template"
@@ -529,6 +627,59 @@ const notificationActionTimestamp = (
</div> </div>
</div> </div>
<div class="grid gap-2">
<Label>Allowed mentions</Label>
<div
:class="[
'grid gap-2',
selectedChannel(notificationAction) !==
'discord'
? 'hidden'
: '',
]"
>
<label
v-for="allowedMentionOption in discordAllowedMentionOptions"
:key="`notification-allowed-mention-${notificationAction.id}-${allowedMentionOption.value}`"
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<input
type="checkbox"
name="channel_configuration[allowed_mentions][]"
:value="
allowedMentionOption.value
"
:default-checked="
notificationActionAllowedMentions(
notificationAction,
).includes(
allowedMentionOption.value,
)
"
:disabled="
selectedChannel(notificationAction) !==
'discord'
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{ allowedMentionOption.label }}
</span>
</label>
</div>
<p class="text-muted-foreground text-sm">
{{
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.'
}}
</p>
<InputError
:message="allowedMentionsError(errors)"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label <Label
:for=" :for="
@@ -106,6 +106,44 @@ class DeliverNotificationActionTest extends TestCase
$this->assertNotNull($deliveryLog->delivered_at); $this->assertNotNull($deliveryLog->delivered_at);
} }
/**
* Ensure Discord deliveries include configured allowed mention parse values.
*/
public function test_discord_delivery_includes_configured_allowed_mentions(): void
{
Http::preventStrayRequests();
Http::fake([
'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response('', 204),
]);
$notificationAction = NotificationAction::factory()->discord()->create([
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles', 'users', 'EVERYONE', 'invalid'],
],
'message_template' => '$ticket_key moved to $status_to',
]);
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
(new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
notificationDeliveryLogId: $deliveryLog->id,
))->handle();
Http::assertSentCount(1);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://discord.com/api/webhooks/123456/discord-alerts'
&& $request->data() === [
'content' => 'CORE-42 moved to Doing',
'allowed_mentions' => [
'parse' => ['users', 'roles', 'everyone'],
],
];
});
}
/** /**
* Ensure retryable webhook responses release the job and update the delivery log. * Ensure retryable webhook responses release the job and update the delivery log.
*/ */
@@ -29,6 +29,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->for($project) ->for($project)
->create([ ->create([
'name' => 'Discord alerts', 'name' => 'Discord alerts',
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
]); ]);
$this->syncProjectPermissions($project, $user, ['edit']); $this->syncProjectPermissions($project, $user, ['edit']);
@@ -41,6 +44,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('project.id', $project->id) ->where('project.id', $project->id)
->where('notificationActions.0.name', 'Discord alerts') ->where('notificationActions.0.name', 'Discord alerts')
->where('notificationActions.0.channel', NotificationChannel::Discord->value) ->where('notificationActions.0.channel', NotificationChannel::Discord->value)
->where('notificationActions.0.channel_configuration.allowed_mentions.0', 'users')
->where('notificationActions.0.channel_configuration.allowed_mentions.1', 'roles')
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value) ->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('notificationActions.0.enabled', true) ->where('notificationActions.0.enabled', true)
->where('notificationActions.0.has_delivery_url', true) ->where('notificationActions.0.has_delivery_url', true)
@@ -83,6 +88,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Discord alerts', 'name' => 'Discord alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value, 'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
'enabled' => '1', 'enabled' => '1',
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url', 'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url',
@@ -95,6 +103,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->assertSame($project->id, $notificationAction->project_id); $this->assertSame($project->id, $notificationAction->project_id);
$this->assertSame(NotificationChannel::Discord, $notificationAction->channel); $this->assertSame(NotificationChannel::Discord, $notificationAction->channel);
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->event_type); $this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->event_type);
$this->assertSame(
['allowed_mentions' => ['users', 'roles']],
$notificationAction->channel_configuration,
);
$this->assertTrue($notificationAction->enabled); $this->assertTrue($notificationAction->enabled);
$this->assertSame( $this->assertSame(
'https://discord.com/api/webhooks/123456/discord-alerts', 'https://discord.com/api/webhooks/123456/discord-alerts',
@@ -124,6 +136,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Ops escalation alerts', 'name' => 'Ops escalation alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value, 'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'everyone'],
],
'enabled' => '1', 'enabled' => '1',
'delivery_url' => '', 'delivery_url' => '',
'message_template' => '$ticket_key moved to $status_to: $url', 'message_template' => '$ticket_key moved to $status_to: $url',
@@ -138,6 +153,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'https://discord.com/api/webhooks/123456/original-secret', 'https://discord.com/api/webhooks/123456/original-secret',
$notificationAction->delivery_url, $notificationAction->delivery_url,
); );
$this->assertSame(
['allowed_mentions' => ['users', 'everyone']],
$notificationAction->channel_configuration,
);
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template); $this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
} }
@@ -257,6 +276,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'channel' => NotificationChannel::Discord->value, 'channel' => NotificationChannel::Discord->value,
'enabled' => 'definitely-not-a-boolean', 'enabled' => 'definitely-not-a-boolean',
'delivery_url' => 'not-a-url', 'delivery_url' => 'not-a-url',
'channel_configuration' => [
'allowed_mentions' => ['not-valid'],
],
'message_template' => '', 'message_template' => '',
]) ])
->assertRedirect(route('projects.settings.notifications', $project)) ->assertRedirect(route('projects.settings.notifications', $project))
@@ -265,6 +287,7 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'event_type', 'event_type',
'enabled', 'enabled',
'delivery_url', 'delivery_url',
'channel_configuration.allowed_mentions.0',
'message_template', 'message_template',
]); ]);
} }