diff --git a/fluxer_api/src/api/channel/services/message/MessageSendService.ts b/fluxer_api/src/api/channel/services/message/MessageSendService.ts index 40b72cb00..4e0c80430 100644 --- a/fluxer_api/src/api/channel/services/message/MessageSendService.ts +++ b/fluxer_api/src/api/channel/services/message/MessageSendService.ts @@ -1088,6 +1088,15 @@ export class MessageSendService { embeds: data.embeds, attachments: data.attachments, }); + const webhookActorId = createUserID(BigInt(webhook.id)); + const existingMessage = await this.deps.operationsHelpers.findExistingMessage({ + userId: webhookActorId, + nonce: data.nonce, + expectedChannelId: channelId, + }); + if (existingMessage) { + return existingMessage; + } let referencedMessage: Message | null = null; let messageSnapshots: Array | undefined; if (data.message_reference) { @@ -1194,15 +1203,17 @@ export class MessageSendService { await this.deps.mentionService.handleMentionTasks({ guildId: channel.guildId, message, - authorId: createUserID(BigInt(webhook.id)), + authorId: webhookActorId, mentionHere: mentionData?.mentionHere ?? false, }); await this.deps.dispatchService.dispatchMessageCreate({ channel, message, requestCache, + nonce: data.nonce, mentionHere: mentionData?.mentionHere ?? false, }); + await this.cacheMessageNonceIfPresent({userId: webhookActorId, nonce: data.nonce, channelId, messageId}); void enqueueDeferredEmbeds().catch((error) => { Logger.warn({error, messageId: messageId.toString()}, 'Failed to enqueue deferred embed extraction'); }); diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 993c35aee..82109db3b 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -46,7 +46,7 @@ import {resolveAssetPath} from '../utils/AssetPaths'; import * as RandomUtils from '../utils/RandomUtils'; import type {IWebhookRepository} from './IWebhookRepository'; import {transform as GitHubTransform} from './transformers/GitHubTransformer'; -import {transformInstatusWebhook} from './transformers/InstatusTransformer'; +import {instatusDeliveryKey, transformInstatusWebhook} from './transformers/InstatusTransformer'; export interface WebhookExecuteMessageData extends Omit { attachments?: WebhookMessageRequest['attachments'] | MessageRequest['attachments']; @@ -430,6 +430,11 @@ export class WebhookService { const {webhookId, token, data, requestCache} = params; const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); await this.assertWebhookGuildChannel(webhook); + const delivery = instatusDeliveryKey(data); + if (delivery) { + const isCached = await this.cacheService.get(`instatus:${webhookId}:${delivery}`); + if (isCached) return; + } const embed = transformInstatusWebhook(data); if (!embed) return; await this.channelService.messages.send.sendWebhookMessage({ @@ -439,6 +444,7 @@ export class WebhookService { avatar: await this.getInstatusWebhookAvatar(webhook.id), requestCache, }); + if (delivery) await this.cacheService.set(`instatus:${webhookId}:${delivery}`, 1, seconds('1 day')); } async dispatchWebhooksUpdate({ diff --git a/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts b/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts index 834fe2777..a32ed92f2 100644 --- a/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts +++ b/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts @@ -80,6 +80,31 @@ describe('Instatus transformer', () => { expect(transformInstatusWebhook(payload)).toBeNull(); }); + it('falls through to the component update when the incident has no name', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + incident: {id: 'inc_1', status: 'INVESTIGATING'}, + component_update: {created_at: '2026-07-06T11:00:00.000Z', new_status: 'MAJOROUTAGE', component_id: 'c_1'}, + component: {id: 'c_1', name: 'API', status: 'MAJOROUTAGE'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('API - major outage'); + expect(embed?.color).toBe(0xe23c39); + }); + + it('falls through to the maintenance when the incident has no name', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + incident: {id: 'inc_1', status: 'INVESTIGATING'}, + maintenance: {name: 'Database upgrade', status: 'INPROGRESS'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('Database upgrade'); + expect(embed?.color).toBe(0x3b82f6); + }); + it('appends "Backfilled" to the footer when the incident is backfilled', () => { const payload: InstatusWebhook = { meta: createMeta(), diff --git a/fluxer_api/src/api/webhook/tests/WebhookExecution.test.ts b/fluxer_api/src/api/webhook/tests/WebhookExecution.test.ts index 0a9f59793..cd2318c59 100644 --- a/fluxer_api/src/api/webhook/tests/WebhookExecution.test.ts +++ b/fluxer_api/src/api/webhook/tests/WebhookExecution.test.ts @@ -2,12 +2,13 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants'; +import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {beforeAll, beforeEach, describe, expect, it} from 'vitest'; import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createChannel, createGuild} from '../../guild/tests/GuildTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; -import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; import { createWebhook, deleteWebhook, @@ -144,6 +145,29 @@ describe('Webhook execution', () => { .execute(); await deleteWebhook(harness, webhook.id, owner.token); }); + it('returns the original message when a webhook execution repeats a nonce', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Webhook Exec Guild'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Test Webhook'); + const nonce = '4815162342'; + const first = await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?wait=true`) + .body({content: 'Retried webhook message', nonce}) + .expect(HTTP_STATUS.OK) + .execute(); + const second = await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}?wait=true`) + .body({content: 'Retried webhook message', nonce}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(second.id).toBe(first.id); + const messages = await createBuilder>(harness, owner.token) + .get(`/channels/${channelId}/messages`) + .execute(); + expect(messages.filter((message) => message.webhook_id === webhook.id)).toHaveLength(1); + await deleteWebhook(harness, webhook.id, owner.token); + }); it('rejects webhook execution with invalid token', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Webhook Exec Guild'); diff --git a/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts index ea1994b87..c3229b5a5 100644 --- a/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts +++ b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {afterEach, beforeEach, describe, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {createTestAccount} from '../../auth/tests/AuthTestUtils'; import {createGuild} from '../../guild/tests/GuildTestUtils'; +import {getMessages} from '../../message/tests/MessageTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; import {HTTP_STATUS} from '../../test/TestConstants'; import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; @@ -23,6 +24,41 @@ function createInstatusMeta() { return {unsubscribe: 'https://status.fluxer.app/unsubscribe?id=1&token=abc', documentation: ''}; } +function createInstatusIncident(overrides: Record = {}) { + return { + id: 'inc_1', + name: 'Elevated API latency', + url: 'https://status.fluxer.app/incident/inc_1', + status: 'Investigating', + backfilled: false, + resolved_at: null, + created_at: '2026-07-06T10:00:00.000Z', + updated_at: '2026-07-06T10:00:00.000Z', + incident_updates: [ + { + id: 'u_1', + incident_id: 'inc_1', + markdown: 'We are currently investigating this incident.', + status: 'Investigating', + created_at: '2026-07-06T10:00:00.000Z', + updated_at: '2026-07-06T10:00:00.000Z', + }, + ], + affected_components: [], + ...overrides, + }; +} + +async function countWebhookMessages( + harness: ApiTestHarness, + token: string, + channelId: string, + webhookId: string, +): Promise { + const messages = await getMessages(harness, token, channelId); + return messages.filter((message) => message.webhook_id === webhookId).length; +} + describe('Webhook Instatus integration', () => { let harness: ApiTestHarness; beforeEach(async () => { @@ -39,35 +75,47 @@ describe('Webhook Instatus integration', () => { const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Webhook'); await createBuilderWithoutAuth(harness) .post(`/webhooks/${webhook.id}/${webhook.token}/instatus`) - .body({ - meta: createInstatusMeta(), - page: createInstatusPage(), - incident: { - id: 'inc_1', - name: 'Elevated API latency', - url: 'https://status.fluxer.app/incident/inc_1', - status: 'Investigating', - backfilled: false, - resolved_at: null, - created_at: '2026-07-06T10:00:00.000Z', - updated_at: '2026-07-06T10:00:00.000Z', - incident_updates: [ - { - id: 'u_1', - incident_id: 'inc_1', - markdown: 'We are currently investigating this incident.', - status: 'Investigating', - created_at: '2026-07-06T10:00:00.000Z', - updated_at: '2026-07-06T10:00:00.000Z', - }, - ], - affected_components: [], - }, - }) + .body({meta: createInstatusMeta(), page: createInstatusPage(), incident: createInstatusIncident()}) .expect(HTTP_STATUS.NO_CONTENT) .execute(); await deleteWebhook(harness, webhook.id, owner.token); }); + it('suppresses a repeated incident callback within the deduplication window', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Instatus Duplicate Guild'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Duplicate Webhook'); + const body = {meta: createInstatusMeta(), page: createInstatusPage(), incident: createInstatusIncident()}; + for (let attempt = 0; attempt < 2; attempt++) { + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/instatus`) + .body(body) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + } + expect(await countWebhookMessages(harness, owner.token, channelId, webhook.id)).toBe(1); + await deleteWebhook(harness, webhook.id, owner.token); + }); + it('processes a callback carrying no identifier every time', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Instatus Unidentified Guild'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Unidentified Webhook'); + const body = { + meta: createInstatusMeta(), + page: createInstatusPage(), + incident: createInstatusIncident({id: null}), + }; + for (let attempt = 0; attempt < 2; attempt++) { + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/instatus`) + .body(body) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + } + expect(await countWebhookMessages(harness, owner.token, channelId, webhook.id)).toBe(2); + await deleteWebhook(harness, webhook.id, owner.token); + }); it('accepts a component status update', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Instatus Component Guild'); diff --git a/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts b/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts index 7d5eec68a..8fff50acf 100644 --- a/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts +++ b/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts @@ -12,6 +12,7 @@ import {parseString, safeUrl} from '../../utils/StringUtils'; type EmbedField = NonNullable[number]; type UpdateLike = { + id?: string | null | undefined; created_at?: string | null | undefined; markdown?: string | null | undefined; }; @@ -227,8 +228,30 @@ function transformComponentUpdate(body: InstatusWebhook): RichEmbedRequest | nul } export function transformInstatusWebhook(body: InstatusWebhook): RichEmbedRequest | null { - if (body.incident) return transformIncident(body); - if (body.maintenance) return transformMaintenance(body); - if (body.component_update || body.component) return transformComponentUpdate(body); + return transformIncident(body) ?? transformMaintenance(body) ?? transformComponentUpdate(body); +} + +export function instatusDeliveryKey(body: InstatusWebhook): string | null { + const incident = body.incident; + if (incident) { + const id = nonEmpty(incident.id); + if (!id) return null; + const latest = pickLatestUpdate(incident.incident_updates); + return `incident:${id}:${nonEmpty(latest?.id) ?? nonEmpty(incident.updated_at) ?? ''}`; + } + const maintenance = body.maintenance; + if (maintenance) { + const id = nonEmpty(maintenance.id); + if (!id) return null; + const latest = pickLatestUpdate(maintenance.maintenance_updates); + return `maintenance:${id}:${nonEmpty(latest?.id) ?? nonEmpty(maintenance.updated_at) ?? ''}`; + } + const update = body.component_update; + if (update) { + const componentId = nonEmpty(update.component_id); + const createdAt = nonEmpty(update.created_at); + if (!componentId || !createdAt) return null; + return `component:${componentId}:${createdAt}:${nonEmpty(update.new_status) ?? ''}`; + } return null; }