mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): correct webhook dedupe and the instatus transforms (#2506)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user