fix(api): correct webhook dedupe and the instatus transforms (#2506)

This commit is contained in:
Hampus
2026-09-06 15:31:43 +02:00
committed by GitHub
parent d07f520b13
commit 1ba9592308
6 changed files with 169 additions and 32 deletions
@@ -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<MessageSnapshot> | 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');
});
+7 -1
View File
@@ -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<WebhookMessageRequest, 'attachments'> {
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<number>(`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({
@@ -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(),
@@ -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<MessageResponse>(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}?wait=true`)
.body({content: 'Retried webhook message', nonce})
.expect(HTTP_STATUS.OK)
.execute();
const second = await createBuilderWithoutAuth<MessageResponse>(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<Array<MessageResponse>>(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');
@@ -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<string, unknown> = {}) {
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<number> {
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');
@@ -12,6 +12,7 @@ import {parseString, safeUrl} from '../../utils/StringUtils';
type EmbedField = NonNullable<RichEmbedRequest['fields']>[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;
}