From dec7b63d079fbbcd74cbc27f5e4a61cfbdbe2bd7 Mon Sep 17 00:00:00 2001 From: Jiralite <33201955+Jiralite@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:06:56 +0100 Subject: [PATCH] feat: Instatus webhooks (#1349) Co-authored-by: Hampus --- fluxer_api/src/api/assets/instatus.webp | Bin 0 -> 9598 bytes fluxer_api/src/api/openapi/openapi.json | 234 ++++++++ .../WebhookRateLimitConfig.ts | 4 + fluxer_api/src/api/utils/StringUtils.ts | 8 + .../src/api/webhook/WebhookController.ts | 26 + .../src/api/webhook/WebhookRequestService.ts | 17 + fluxer_api/src/api/webhook/WebhookService.ts | 38 +- .../webhook/tests/InstatusTransformer.test.ts | 509 ++++++++++++++++++ .../api/webhook/tests/WebhookInstatus.test.ts | 117 ++++ .../transformers/InstatusTransformer.ts | 234 ++++++++ .../webhook/transformers/SlackTransformer.ts | 9 +- fluxer_unfurl/src/router_impl.rs | 1 - .../domains/message/MessageRequestSchemas.ts | 39 +- .../domains/webhook/InstatusWebhookSchemas.ts | 91 ++++ 14 files changed, 1300 insertions(+), 27 deletions(-) create mode 100644 fluxer_api/src/api/assets/instatus.webp create mode 100644 fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts create mode 100644 fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts create mode 100644 fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts create mode 100644 packages/schema/src/domains/webhook/InstatusWebhookSchemas.ts diff --git a/fluxer_api/src/api/assets/instatus.webp b/fluxer_api/src/api/assets/instatus.webp new file mode 100644 index 0000000000000000000000000000000000000000..a9704c116c2f8d7e0f29d255641c42a6177fd5dc GIT binary patch literal 9598 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 o4tZ9YD5v>%ew&}+0Nnr>sF>( { + const {webhook_id: webhookId, token} = ctx.req.valid('param'); + await ctx.get('webhookRequestService').executeInstatusWebhook({ + webhookId: createWebhookID(webhookId), + token: createWebhookToken(token), + data: ctx.req.valid('json'), + requestCache: ctx.get('requestCache'), + }); + return ctx.body(null, 204); + }, + ); app.post('/webhooks/livekit', async (ctx) => { const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({ body: await ctx.req.text(), diff --git a/fluxer_api/src/api/webhook/WebhookRequestService.ts b/fluxer_api/src/api/webhook/WebhookRequestService.ts index d202835e7..6bebaf848 100644 --- a/fluxer_api/src/api/webhook/WebhookRequestService.ts +++ b/fluxer_api/src/api/webhook/WebhookRequestService.ts @@ -2,6 +2,7 @@ import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; +import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; import type { SlackWebhookRequest, WebhookCreateRequest, @@ -136,6 +137,13 @@ interface WebhookExecuteSlackParams { requestCache: RequestCache; } +interface WebhookExecuteInstatusParams { + webhookId: WebhookID; + token: WebhookToken; + data: InstatusWebhook; + requestCache: RequestCache; +} + interface LiveKitWebhookParams { body: string; authHeader?: string; @@ -305,6 +313,15 @@ export class WebhookRequestService { }); } + async executeInstatusWebhook(params: WebhookExecuteInstatusParams): Promise { + await this.webhookService.executeInstatusWebhook({ + webhookId: params.webhookId, + token: params.token, + data: params.data, + requestCache: params.requestCache, + }); + } + async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise { await this.webhookService.executeWebhook({ webhookId: params.webhookId, diff --git a/fluxer_api/src/api/webhook/WebhookService.ts b/fluxer_api/src/api/webhook/WebhookService.ts index 79c1ce898..77aefd02e 100644 --- a/fluxer_api/src/api/webhook/WebhookService.ts +++ b/fluxer_api/src/api/webhook/WebhookService.ts @@ -13,6 +13,7 @@ import {MaxWebhooksPerGuildError} from '@fluxer/errors/src/domains/guild/MaxWebh import {UnknownWebhookError} from '@fluxer/errors/src/domains/webhook/UnknownWebhookError'; import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas'; import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; +import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; import type { WebhookCreateRequest, WebhookMessageRequest, @@ -44,6 +45,7 @@ import type {Webhook} from '../models/Webhook'; import * as RandomUtils from '../utils/RandomUtils'; import type {IWebhookRepository} from './IWebhookRepository'; import {transform as GitHubTransform} from './transformers/GitHubTransformer'; +import {transformInstatusWebhook} from './transformers/InstatusTransformer'; export interface WebhookExecuteMessageData extends Omit { attachments?: WebhookMessageRequest['attachments'] | MessageRequest['attachments']; @@ -89,6 +91,11 @@ interface WebhookExecuteGitHubParams extends WebhookTokenParams { requestCache: RequestCache; } +interface WebhookExecuteInstatusParams extends WebhookTokenParams { + data: InstatusWebhook; + requestCache: RequestCache; +} + const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__'; export class WebhookService { @@ -408,6 +415,21 @@ export class WebhookService { if (delivery) await this.cacheService.set(`github:${webhookId}:${delivery}`, 1, seconds('1 day')); } + async executeInstatusWebhook(params: WebhookExecuteInstatusParams): Promise { + const {webhookId, token, data, requestCache} = params; + const webhook = await this.getTokenAuthenticatedWebhook({webhookId, token}); + await this.assertWebhookGuildChannel(webhook); + const embed = transformInstatusWebhook(data); + if (!embed) return; + await this.channelService.messages.send.sendWebhookMessage({ + webhook, + data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS}, + username: 'Instatus', + avatar: await this.getInstatusWebhookAvatar(webhook.id), + requestCache, + }); + } + async dispatchWebhooksUpdate({ guildId, channelId, @@ -539,26 +561,24 @@ export class WebhookService { } private async getGitHubWebhookAvatar(webhookId: WebhookID): Promise { - return this.getStaticWebhookAvatar({ - webhookId, - provider: 'github', - assetFileName: 'github.webp', - }); + return this.getStaticWebhookAvatar({webhookId, provider: 'github'}); + } + + private async getInstatusWebhookAvatar(webhookId: WebhookID): Promise { + return this.getStaticWebhookAvatar({webhookId, provider: 'instatus'}); } private async getStaticWebhookAvatar({ webhookId, provider, - assetFileName, }: { webhookId: WebhookID; - provider: 'github'; - assetFileName: 'github.webp'; + provider: 'github' | 'instatus'; }): Promise { const cacheKey = `webhook:${webhookId}:avatar:${provider}`; const avatarCache = await this.cacheService.get(cacheKey); if (avatarCache) return avatarCache; - const avatarFile = await fs.readFile(new URL(`../assets/${assetFileName}`, import.meta.url)); + const avatarFile = await fs.readFile(new URL(`../assets/${provider}.webp`, import.meta.url)); const avatar = await this.avatarService.uploadAvatar({ prefix: 'avatars', entityId: webhookId, diff --git a/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts b/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts new file mode 100644 index 000000000..834fe2777 --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/InstatusTransformer.test.ts @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {RICH_EMBED_DESCRIPTION_MAX_LENGTH} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; +import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; +import {describe, expect, it} from 'vitest'; +import {transformInstatusWebhook} from '../transformers/InstatusTransformer'; + +function createMeta(): InstatusWebhook['meta'] { + return {unsubscribe: 'https://fluxerstatus.com/unsubscribe?id=1&token=abc', documentation: ''}; +} + +function createPage(): InstatusWebhook['page'] { + return { + id: 'pg_123', + status_indicator: 'HASISSUES', + status_description: 'Partially degraded service', + url: 'https://fluxerstatus.com', + }; +} + +describe('Instatus transformer', () => { + describe('incidents', () => { + it('transforms an investigating incident', () => { + const payload: InstatusWebhook = { + meta: {unsubscribe: 'https://fluxerstatus.com/unsubscribe?id=1&token=abc', documentation: ''}, + page: createPage(), + incident: { + id: 'inc_1', + name: 'Elevated API latency', + url: 'https://fluxerstatus.com/incident/inc_1', + status: 'INVESTIGATING', + backfilled: false, + created_at: '2026-07-06T10:00:00.000Z', + updated_at: '2026-07-06T10:00:00.000Z', + resolved_at: '', + incident_updates: [ + { + id: 'u_1', + incident_id: 'inc_1', + markdown: 'We are investigating serious levels of pain in the API. Ambulances are on standby.', + status: 'INVESTIGATING', + created_at: '2026-07-06T10:00:00.000Z', + updated_at: '2026-07-06T10:00:00.000Z', + }, + ], + }, + }; + const embed = transformInstatusWebhook(payload); + expect(embed).not.toBeNull(); + expect(embed?.title).toBe('Elevated API latency'); + expect(embed?.url).toBe('https://fluxerstatus.com/incident/inc_1'); + expect(embed?.color).toBe(0xe23c39); + expect(embed?.description).toBe( + 'We are investigating serious levels of pain in the API. Ambulances are on standby.', + ); + expect(embed?.fields?.[0]).toEqual({name: 'Status', value: 'Investigating', inline: true}); + expect(embed?.footer?.text).toBe('Partially degraded service'); + expect(embed?.timestamp).toBeInstanceOf(Date); + }); + + it('colours a resolved incident green and omits the description when there is no update', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + incident: { + name: 'Elevated API latency', + url: 'https://fluxerstatus.com/incident/inc_1', + status: 'RESOLVED', + incident_updates: [], + }, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('Elevated API latency'); + expect(embed?.color).toBe(0x3ecf8e); + expect(embed?.description).toBeUndefined(); + }); + + it('returns null when the incident has no name', () => { + const payload: InstatusWebhook = {meta: createMeta(), page: createPage(), incident: {status: 'INVESTIGATING'}}; + expect(transformInstatusWebhook(payload)).toBeNull(); + }); + + it('appends "Backfilled" to the footer when the incident is backfilled', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + incident: {name: 'Backdated outage', status: 'RESOLVED', backfilled: true}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.footer?.text).toBe('Partially degraded service | Backfilled'); + }); + }); + + describe('maintenances', () => { + it('transforms an in-progress maintenance', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: {...createPage(), status_indicator: 'UNDERMAINTENANCE', status_description: 'Under Maintenance'}, + maintenance: { + name: 'Database upgrade', + url: 'https://fluxerstatus.com/maintenance/m_1', + status: 'INPROGRESS', + maintenance_updates: [ + { + markdown: 'Maintenance has started. No idea what to maintain, though.', + created_at: '2026-07-06T09:00:00.000Z', + }, + ], + }, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('Database upgrade'); + expect(embed?.url).toBe('https://fluxerstatus.com/maintenance/m_1'); + expect(embed?.color).toBe(0x3b82f6); + expect(embed?.description).toBe('Maintenance has started. No idea what to maintain, though.'); + expect(embed?.fields?.[0]).toEqual({name: 'Status', value: 'In progress', inline: true}); + expect(embed?.footer?.text).toBe('Under Maintenance'); + }); + + it('colours a completed maintenance green', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + maintenance: {name: 'Database downgrade (trust)', status: 'COMPLETED'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('Database downgrade (trust)'); + expect(embed?.color).toBe(0x3ecf8e); + }); + }); + + describe('component updates', () => { + it('transforms a component going down', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: {...createPage(), status_description: 'Major outage'}, + 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', created_at: '2026-01-01T00:00:00.000Z'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('API - major outage'); + expect(embed?.url).toBe('https://fluxerstatus.com'); + expect(embed?.color).toBe(0xe23c39); + expect(embed?.footer?.text).toBe('Major outage'); + expect(embed?.timestamp).toBeInstanceOf(Date); + }); + + it('colours an operational component green', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + component_update: {new_status: 'OPERATIONAL', component_id: 'c_1'}, + component: {id: 'c_1', name: 'API', status: 'OPERATIONAL'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('API - operational'); + expect(embed?.color).toBe(0x3ecf8e); + }); + + it('normalises title case statuses with spaces (real casing)', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: createPage(), + component_update: {new_status: 'Major outage', component_id: 'c_1'}, + component: {id: 'c_1', name: 'API', status: 'Major outage'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.title).toBe('API - major outage'); + expect(embed?.color).toBe(0xe23c39); + }); + }); + + describe('robustness', () => { + it('accepts an empty payload and returns null', () => { + expect(transformInstatusWebhook(InstatusWebhook.parse({}))).toBeNull(); + }); + + it('accepts nullish event sections and returns null', () => { + expect( + transformInstatusWebhook( + InstatusWebhook.parse({ + meta: null, + page: null, + incident: null, + maintenance: null, + component_update: null, + component: null, + }), + ), + ).toBeNull(); + }); + + it('drops non-http(s) urls', () => { + const payload: InstatusWebhook = { + meta: createMeta(), + page: {url: 'javascript:alert(1)'}, + incident: {name: 'Outage', status: 'INVESTIGATING', url: 'not-a-url'}, + }; + const embed = transformInstatusWebhook(payload); + expect(embed?.url).toBeUndefined(); + }); + }); + + describe('Instatus delivery', () => { + it('parses and renders an incident payload', () => { + const raw = { + meta: { + unsubscribe: + 'https://jiralite.instatus.com/unsubscribe?id=cmr9oat950nga0kqg1688424b&token=221d8f1d-7e2f-43d1-b528-af72e7bb098c', + documentation: '', + }, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'UP', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + incident: { + backfilled: false, + created_at: '2026-07-06T20:34:47.965Z', + name: 'test', + resolved_at: null, + status: 'Investigating', + updated_at: '2026-07-06T20:34:47.965Z', + id: 'cmr9oi6n11bde0rmj0fooyzcc', + incident_updates: [ + { + id: 'cmr9oi6wh1bdf0rmj5ii7bf0d', + incident_id: 'cmr9oi6n11bde0rmj0fooyzcc', + markdown: 'Hope Kevin Fang picks this incident up because we are so bored.', + status: 'Investigating', + created_at: '2026-07-06T20:34:48.305Z', + updated_at: '2026-07-06T20:34:48.305Z', + }, + ], + affected_components: [], + url: 'https://jiralite.instatus.com/cmr9oi6n11bde0rmj0fooyzcc', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('test'); + expect(embed?.color).toBe(0xe23c39); + expect(embed?.description).toBe('Hope Kevin Fang picks this incident up because we are so bored.'); + expect(embed?.url).toBe('https://jiralite.instatus.com/cmr9oi6n11bde0rmj0fooyzcc'); + expect(embed?.fields?.[0]).toEqual({name: 'Status', value: 'Investigating', inline: true}); + expect(embed?.footer?.text).toBe('All systems operational'); + expect(embed?.timestamp).toBeInstanceOf(Date); + }); + + it('uses the latest update and lists affected components', () => { + const raw = { + meta: { + unsubscribe: + 'https://jiralite.instatus.com/unsubscribe?id=cmr9oat950nga0kqg1688424b&token=221d8f1d-7e2f-43d1-b528-af72e7bb098c', + documentation: '', + }, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'HASISSUES', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + incident: { + backfilled: false, + created_at: '2026-07-06T20:43:40.300Z', + name: 'Basic incident', + resolved_at: null, + status: 'Identified', + updated_at: '2026-07-06T20:44:41.287Z', + id: 'cmr9otle41c9q0rqff1okyv7q', + incident_updates: [ + { + id: 'cmr9otlob1c9r0rqf1mvgixhn', + incident_id: 'cmr9otle41c9q0rqff1okyv7q', + markdown: 'Hope Kevin Fang picks this incident up because we are so bored.', + status: 'Investigating', + created_at: '2026-07-06T20:43:40.667Z', + updated_at: '2026-07-06T20:44:41.281Z', + }, + { + id: 'cmr9ouwgg1ca00rqfy39xauwg', + incident_id: 'cmr9otle41c9q0rqff1okyv7q', + markdown: 'Update - identified.', + status: 'Identified', + created_at: '2026-07-06T20:44:41.296Z', + updated_at: '2026-07-06T20:44:41.296Z', + }, + ], + affected_components: [ + {id: 'cmr9osz9s1c9p0rqfgeiwp97t', name: 'Component', status: 'Major outage'}, + {id: 'cmr9ot5ld15is1aqfjptygehv', name: 'Component with no group', status: 'Major outage'}, + ], + url: 'https://jiralite.instatus.com/cmr9otle41c9q0rqff1okyv7q', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('Basic incident'); + expect(embed?.color).toBe(0xf97316); + expect(embed?.description).toBe('Update - identified.'); + const affected = embed?.fields?.find((entry) => entry.name === 'Affected components'); + expect(affected?.inline).toBe(false); + expect(affected?.value).toBe('- Component (major outage)\n- Component with no group (major outage)'); + }); + + it('parses a component major outage', () => { + const raw = { + meta: { + unsubscribe: + 'https://jiralite.instatus.com/unsubscribe?id=cmr9oat950nga0kqg1688424b&token=221d8f1d-7e2f-43d1-b528-af72e7bb098c', + documentation: '', + }, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'HASISSUES', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + component_update: { + created_at: '2026-07-06T20:44:44.403Z', + new_status: 'MAJOROUTAGE', + component_id: 'cmr9osz9s1c9p0rqfgeiwp97t', + }, + component: { + created_at: '2026-07-06T20:43:11.632Z', + id: 'cmr9osz9s1c9p0rqfgeiwp97t', + name: 'Component', + status: 'MAJOROUTAGE', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('Component - major outage'); + expect(embed?.color).toBe(0xe23c39); + expect(embed?.url).toBe('https://jiralite.instatus.com'); + }); + + it('parses a component going under maintenance', () => { + const raw = { + meta: { + unsubscribe: + 'https://jiralite.instatus.com/unsubscribe?id=cmr9oat950nga0kqg1688424b&token=221d8f1d-7e2f-43d1-b528-af72e7bb098c', + documentation: '', + }, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'HASISSUES', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + component_update: { + created_at: '2026-07-06T20:43:43.775Z', + new_status: 'UNDERMAINTENANCE', + component_id: 'cmr9ot5ld15is1aqfjptygehv', + }, + component: { + created_at: '2026-07-06T20:43:19.825Z', + id: 'cmr9ot5ld15is1aqfjptygehv', + name: 'Component with no group', + status: 'UNDERMAINTENANCE', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('Component with no group - under maintenance'); + expect(embed?.color).toBe(0x3b82f6); + }); + + it('accepts a very long update and truncates the rendered description', () => { + const sentence = + 'We implemented a fix somewhere. We have no idea where. In fact, not even sure it was implemented.'; + const raw = { + meta: {unsubscribe: '', documentation: ''}, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'HASISSUES', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + incident: { + backfilled: false, + created_at: '2026-07-06T21:51:54.422Z', + name: 'Monitoring', + resolved_at: null, + status: 'Monitoring', + updated_at: '2026-07-06T21:51:54.422Z', + id: 'cmr9r9cfq008f1apmu4mt0g4f', + incident_updates: [ + { + id: 'cmr9r9cg7008g1apm0cvk6ob4', + incident_id: 'cmr9r9cfq008f1apmu4mt0g4f', + markdown: Array.from({length: 150}, () => sentence).join('\n\n'), + status: 'Monitoring', + created_at: '2026-07-06T21:51:54.439Z', + updated_at: '2026-07-06T21:51:54.439Z', + }, + ], + affected_components: [{id: 'cmr9osz9s1c9p0rqfgeiwp97t', name: 'Component', status: 'Major outage'}], + url: 'https://jiralite.instatus.com/cmr9r9cfq008f1apmu4mt0g4f', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('Monitoring'); + expect(embed?.color).toBe(0xf5a623); + expect((embed?.description ?? '').length).toBeLessThanOrEqual(RICH_EMBED_DESCRIPTION_MAX_LENGTH); + expect(embed?.description?.startsWith('We implemented a fix')).toBe(true); + }); + + it('renders a resolved incident from its latest update', () => { + const sentence = + 'We implemented a fix somewhere. We have no idea where. In fact, not even sure it was implemented.'; + const raw = { + meta: {unsubscribe: '', documentation: ''}, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'UNDERMAINTENANCE', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + incident: { + backfilled: false, + created_at: '2026-07-06T21:51:54.422Z', + name: 'Monitoring', + resolved_at: '2026-07-06T21:53:27.289Z', + status: 'Resolved', + updated_at: '2026-07-06T21:53:27.663Z', + id: 'cmr9r9cfq008f1apmu4mt0g4f', + incident_updates: [ + { + id: 'cmr9r9cg7008g1apm0cvk6ob4', + incident_id: 'cmr9r9cfq008f1apmu4mt0g4f', + markdown: Array.from({length: 150}, () => sentence).join('\n\n'), + status: 'Monitoring', + created_at: '2026-07-06T21:51:54.439Z', + updated_at: '2026-07-06T21:53:27.655Z', + }, + { + id: 'cmr9rbce7008v1apmlpf6zh5x', + incident_id: 'cmr9r9cfq008f1apmu4mt0g4f', + markdown: 'This incident has been resolved.', + status: 'Resolved', + created_at: '2026-07-06T21:53:27.679Z', + updated_at: '2026-07-06T21:53:27.679Z', + }, + ], + affected_components: [{id: 'cmr9osz9s1c9p0rqfgeiwp97t', name: 'Component', status: 'Under maintenance'}], + url: 'https://jiralite.instatus.com/cmr9r9cfq008f1apmu4mt0g4f', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('Monitoring'); + expect(embed?.color).toBe(0x3ecf8e); + expect(embed?.description).toBe('This incident has been resolved.'); + const affected = embed?.fields?.find((entry) => entry.name === 'Affected components'); + expect(affected?.value).toBe('Component (under maintenance)'); + }); + + it('renders a scheduled maintenance', () => { + const raw = { + meta: {unsubscribe: '', documentation: ''}, + page: { + id: 'cmr9kdnbr0mh40rqg5opm1nrj', + status_indicator: 'UNDERMAINTENANCE', + status_description: '', + url: 'https://jiralite.instatus.com', + }, + maintenance: { + backfilled: false, + maintenance_start_date: '2026-07-07T08:00:00.000Z', + maintenance_end_date: '2026-07-07T09:00:00.000Z', + created_at: '2026-07-06T21:56:13.780Z', + name: 'scheduled', + resolved_at: null, + status: 'Planned', + updated_at: '2026-07-06T21:56:13.780Z', + id: 'cmr9rewk400811ao2gp7ytf92', + maintenance_updates: [ + { + id: 'cmr9rewtu00821ao2agy436tp', + maintenance_id: 'cmr9rewk400811ao2gp7ytf92', + markdown: + 'We are planning maintenance at this time. Just kidding, our database administrators just want to go outside. We used to call them on their cell phone too.', + created_at: '2026-07-06T21:56:14.130Z', + updated_at: '2026-07-06T21:56:14.130Z', + }, + ], + affected_components: [ + {id: 'cmr9ot5ld15is1aqfjptygehv', name: 'Component with no group', status: 'Operational'}, + ], + url: 'https://jiralite.instatus.com/cmr9rewk400811ao2gp7ytf92', + }, + }; + const embed = transformInstatusWebhook(InstatusWebhook.parse(raw)); + expect(embed?.title).toBe('scheduled'); + expect(embed?.color).toBe(0x3b82f6); + expect(embed?.description).toBe( + 'We are planning maintenance at this time. Just kidding, our database administrators just want to go outside. We used to call them on their cell phone too.', + ); + const affected = embed?.fields?.find((entry) => entry.name === 'Affected components'); + expect(affected?.value).toBe('Component with no group (operational)'); + const windowEntry = embed?.fields?.find((entry) => entry.name === 'Window'); + const startUnix = Math.floor(Date.parse('2026-07-07T08:00:00.000Z') / 1000); + const endUnix = Math.floor(Date.parse('2026-07-07T09:00:00.000Z') / 1000); + expect(windowEntry?.value).toBe(`-`); + expect(windowEntry?.inline).toBe(false); + }); + }); +}); diff --git a/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts new file mode 100644 index 000000000..ea1994b87 --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, it} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {createGuild} from '../../guild/tests/GuildTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {createWebhook, deleteWebhook} from './WebhookTestUtils'; + +function createInstatusPage() { + return { + id: 'pg_123', + status_indicator: 'HASISSUES', + status_description: 'Partially degraded service', + url: 'https://status.fluxer.app', + }; +} + +function createInstatusMeta() { + // `documentation` seems to always be an empty string. + // If only we had documentation about it. + return {unsubscribe: 'https://status.fluxer.app/unsubscribe?id=1&token=abc', documentation: ''}; +} + +describe('Webhook Instatus integration', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + describe('POST /webhooks/:webhook_id/:token/instatus', () => { + it('accepts an incident notification', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Instatus Incident Guild'); + const channelId = guild.system_channel_id!; + 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: [], + }, + }) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + 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'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Component Webhook'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/instatus`) + .body({ + meta: createInstatusMeta(), + page: {...createInstatusPage(), status_indicator: 'UP', status_description: 'All Systems Operational'}, + component_update: {created_at: '2026-07-06T11:00:00.000Z', new_status: 'Major outage', component_id: 'c_1'}, + component: {id: 'c_1', name: 'API', status: 'Major outage', created_at: '2026-01-01T00:00:00.000Z'}, + }) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + await deleteWebhook(harness, webhook.id, owner.token); + }); + it('accepts an empty selected payload without sending', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Instatus Empty Guild'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Empty Webhook'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/${webhook.token}/instatus`) + .body({meta: createInstatusMeta(), page: createInstatusPage()}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + await deleteWebhook(harness, webhook.id, owner.token); + }); + it('rejects an instatus webhook with an invalid token', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'Instatus Invalid Token Guild'); + const channelId = guild.system_channel_id!; + const webhook = await createWebhook(harness, channelId, owner.token, 'Instatus Invalid Webhook'); + await createBuilderWithoutAuth(harness) + .post(`/webhooks/${webhook.id}/invalid_token/instatus`) + .body({ + meta: createInstatusMeta(), + page: createInstatusPage(), + incident: {name: 'Outage', status: 'INVESTIGATING'}, + }) + .expect(HTTP_STATUS.NOT_FOUND) + .execute(); + await deleteWebhook(harness, webhook.id, owner.token); + }); + }); +}); diff --git a/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts b/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts new file mode 100644 index 000000000..7d5eec68a --- /dev/null +++ b/fluxer_api/src/api/webhook/transformers/InstatusTransformer.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + RICH_EMBED_DESCRIPTION_MAX_LENGTH, + RICH_EMBED_FIELD_VALUE_MAX_LENGTH, + RICH_EMBED_FOOTER_TEXT_MAX_LENGTH, + RICH_EMBED_TITLE_MAX_LENGTH, + type RichEmbedRequest, +} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; +import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; +import {parseString, safeUrl} from '../../utils/StringUtils'; + +type EmbedField = NonNullable[number]; +type UpdateLike = { + created_at?: string | null | undefined; + markdown?: string | null | undefined; +}; +type ComponentLike = {name?: string | null | undefined; status?: string | null | undefined}; + +const COLOUR_OPERATIONAL = 0x3ecf8e as const; +const COLOUR_DEGRADED = 0xf5a623 as const; +const COLOUR_PARTIAL_OUTAGE = 0xf97316 as const; +const COLOUR_MAJOR_OUTAGE = 0xe23c39 as const; +const COLOUR_MAINTENANCE = 0x3b82f6 as const; +const COLOUR_NEUTRAL = 0x5c6bc0 as const; + +const STATUS_LABELS: Record = { + // Page. + UP: 'All systems operational', + HASISSUES: 'Has issues', + // Component. + OPERATIONAL: 'Operational', + UNDERMAINTENANCE: 'Under maintenance', + DEGRADEDPERFORMANCE: 'Degraded performance', + PARTIALOUTAGE: 'Partial outage', + MAJOROUTAGE: 'Major outage', + // Incident. + INVESTIGATING: 'Investigating', + IDENTIFIED: 'Identified', + MONITORING: 'Monitoring', + RESOLVED: 'Resolved', + // Maintenance. + /** + * @remarks Documented but not observable. May have swapped to `PLANNED` at some point. + */ + NOTSTARTEDYET: 'Scheduled', + /** + * @remarks Undocumented but observable. May be `NOTSTARTEDYET`'s replacement. + */ + PLANNED: 'Planned', + INPROGRESS: 'In progress', + COMPLETED: 'Completed', +}; + +/** + * @remarks Instatus is inconsistent about casing. `page.status_indicator` arrives as + * SCREAMINGCASE ("UP") whilst incident and maintenance statuses arrive in title case. + */ +function normaliseStatusToken(status: string): string { + return status.toUpperCase().replace(/[^A-Z]/g, ''); +} + +function humaniseStatus(status: string): string { + return STATUS_LABELS[normaliseStatusToken(status)] ?? status; +} + +const STATUS_COLOURS: Record = { + // Component. + OPERATIONAL: COLOUR_OPERATIONAL, + UNDERMAINTENANCE: COLOUR_MAINTENANCE, + DEGRADEDPERFORMANCE: COLOUR_DEGRADED, + PARTIALOUTAGE: COLOUR_PARTIAL_OUTAGE, + MAJOROUTAGE: COLOUR_MAJOR_OUTAGE, + // Incident. + INVESTIGATING: COLOUR_MAJOR_OUTAGE, + IDENTIFIED: COLOUR_PARTIAL_OUTAGE, + MONITORING: COLOUR_DEGRADED, + RESOLVED: COLOUR_OPERATIONAL, + // Maintenance. + NOTSTARTEDYET: COLOUR_MAINTENANCE, + PLANNED: COLOUR_MAINTENANCE, + INPROGRESS: COLOUR_MAINTENANCE, + COMPLETED: COLOUR_OPERATIONAL, +}; + +function statusColour(status: string): number { + return STATUS_COLOURS[normaliseStatusToken(status)] ?? COLOUR_NEUTRAL; +} + +function nonEmpty(value: string | null | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function toDate(value: string | null | undefined): Date | undefined { + if (typeof value !== 'string' || value.length === 0) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function pickLatestUpdate(updates: ReadonlyArray | null | undefined): UpdateLike | undefined { + if (!updates || updates.length === 0) return undefined; + return updates.reduce((latest, update) => + new Date(update.created_at ?? 0).getTime() > new Date(latest.created_at ?? 0).getTime() ? update : latest, + ); +} + +function statusField(status: string | null | undefined): EmbedField | undefined { + const value = nonEmpty(status); + if (!value) return undefined; + return { + name: 'Status', + value: parseString(humaniseStatus(value), RICH_EMBED_FIELD_VALUE_MAX_LENGTH), + inline: true, + }; +} + +function affectedField(components: ReadonlyArray | null | undefined): EmbedField | undefined { + if (!components || components.length === 0) return undefined; + const parts: Array = []; + for (const component of components) { + const name = nonEmpty(component.name); + if (!name) continue; + const status = nonEmpty(component.status); + const statusLabel = status ? humaniseStatus(status).toLowerCase() : undefined; + parts.push(statusLabel ? `${name} (${statusLabel})` : name); + } + if (parts.length === 0) return undefined; + const value = parts.length === 1 ? parts[0] : parts.map((part) => `- ${part}`).join('\n'); + return { + name: 'Affected components', + value: parseString(value, RICH_EMBED_FIELD_VALUE_MAX_LENGTH), + inline: false, + }; +} + +function timestampMarkup(value: string | null | undefined): string | undefined { + const date = toDate(value); + return date ? `` : undefined; +} + +function windowField(startDate: string | null | undefined, endDate: string | null | undefined): EmbedField | undefined { + const start = timestampMarkup(startDate); + const end = timestampMarkup(endDate); + const value = start && end ? `${start}-${end}` : (start ?? end); + if (!value) return undefined; + return {name: 'Window', value, inline: false}; +} + +function footer(page: InstatusWebhook['page'], backfilled?: boolean | null): RichEmbedRequest['footer'] { + const indicator = nonEmpty(page?.status_indicator); + const base = nonEmpty(page?.status_description) ?? (indicator ? humaniseStatus(indicator) : undefined); + const parts: Array = []; + if (base) parts.push(base); + if (backfilled) parts.push('Backfilled'); + if (parts.length === 0) return undefined; + return {text: parseString(parts.join(' | '), RICH_EMBED_FOOTER_TEXT_MAX_LENGTH)}; +} + +function addFields(fields: Array, entries: ReadonlyArray): void { + for (const entry of entries) { + if (entry !== undefined) fields.push(entry); + } +} + +function transformIncident(body: InstatusWebhook): RichEmbedRequest | null { + const incident = body.incident; + if (!incident) return null; + const name = nonEmpty(incident.name); + if (!name) return null; + const status = nonEmpty(incident.status); + const latest = pickLatestUpdate(incident.incident_updates); + const markdown = nonEmpty(latest?.markdown); + const fields: Array = []; + addFields(fields, [statusField(status), affectedField(incident.affected_components)]); + return { + title: parseString(name, RICH_EMBED_TITLE_MAX_LENGTH), + url: safeUrl(incident.url) ?? safeUrl(body.page?.url), + color: status ? statusColour(status) : COLOUR_NEUTRAL, + description: markdown ? parseString(markdown, RICH_EMBED_DESCRIPTION_MAX_LENGTH) : undefined, + fields: fields.length > 0 ? fields : undefined, + footer: footer(body.page, incident.backfilled), + timestamp: toDate(latest?.created_at) ?? toDate(incident.updated_at) ?? toDate(incident.created_at), + }; +} + +function transformMaintenance(body: InstatusWebhook): RichEmbedRequest | null { + const maintenance = body.maintenance; + if (!maintenance) return null; + const name = nonEmpty(maintenance.name); + if (!name) return null; + const status = nonEmpty(maintenance.status); + const latest = pickLatestUpdate(maintenance.maintenance_updates); + const markdown = nonEmpty(latest?.markdown); + const fields: Array = []; + addFields(fields, [ + statusField(status), + windowField(maintenance.maintenance_start_date, maintenance.maintenance_end_date), + affectedField(maintenance.affected_components), + ]); + return { + title: parseString(name, RICH_EMBED_TITLE_MAX_LENGTH), + url: safeUrl(maintenance.url) ?? safeUrl(body.page?.url), + color: status ? statusColour(status) : COLOUR_MAINTENANCE, + description: markdown ? parseString(markdown, RICH_EMBED_DESCRIPTION_MAX_LENGTH) : undefined, + fields: fields.length > 0 ? fields : undefined, + footer: footer(body.page, maintenance.backfilled), + timestamp: toDate(latest?.created_at) ?? toDate(maintenance.updated_at) ?? toDate(maintenance.created_at), + }; +} + +function transformComponentUpdate(body: InstatusWebhook): RichEmbedRequest | null { + const update = body.component_update; + const component = body.component; + if (!update && !component) return null; + const newStatus = nonEmpty(update?.new_status) ?? nonEmpty(component?.status); + const componentName = nonEmpty(component?.name) ?? 'A component'; + const statusLabel = newStatus ? humaniseStatus(newStatus).toLowerCase() : 'updated'; + return { + title: parseString(`${componentName} - ${statusLabel}`, RICH_EMBED_TITLE_MAX_LENGTH), + url: safeUrl(body.page?.url), + color: newStatus ? statusColour(newStatus) : COLOUR_NEUTRAL, + footer: footer(body.page), + timestamp: toDate(update?.created_at) ?? toDate(component?.created_at), + }; +} + +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 null; +} diff --git a/fluxer_api/src/api/webhook/transformers/SlackTransformer.ts b/fluxer_api/src/api/webhook/transformers/SlackTransformer.ts index fdc0187d4..bbc8b2bac 100644 --- a/fluxer_api/src/api/webhook/transformers/SlackTransformer.ts +++ b/fluxer_api/src/api/webhook/transformers/SlackTransformer.ts @@ -5,7 +5,7 @@ import type { WebhookMessageRequest, } from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas'; import {ColorType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; -import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; +import {safeUrl} from '../../utils/StringUtils'; type SlackAttachment = NonNullable[number]; type SlackAttachmentField = NonNullable[number]; @@ -79,13 +79,6 @@ function buildAttachmentDescription(att: SlackAttachment): string | undefined { return combined.length > 0 ? combined : undefined; } -function safeUrl(value: unknown): string | undefined { - if (typeof value !== 'string' || value.length === 0) return undefined; - if (!value.startsWith('http://') && !value.startsWith('https://')) return undefined; - const parsed = URLType.safeParse(value); - return parsed.success ? parsed.data : undefined; -} - function safeHexColor(value: unknown): number | undefined { if (typeof value !== 'string' || value.length === 0) return undefined; const match = value.match(/^#?([0-9a-fA-F]{6})$/); diff --git a/fluxer_unfurl/src/router_impl.rs b/fluxer_unfurl/src/router_impl.rs index d8e634fa7..7e475a0aa 100644 --- a/fluxer_unfurl/src/router_impl.rs +++ b/fluxer_unfurl/src/router_impl.rs @@ -64,7 +64,6 @@ impl RouterService for UnfurlRouter { url, nsfw_mode, bypass_cache, - cache_only: _, .. } => { if *bypass_cache { diff --git a/packages/schema/src/domains/message/MessageRequestSchemas.ts b/packages/schema/src/domains/message/MessageRequestSchemas.ts index 822932d2a..05f2a4bfc 100644 --- a/packages/schema/src/domains/message/MessageRequestSchemas.ts +++ b/packages/schema/src/domains/message/MessageRequestSchemas.ts @@ -22,6 +22,15 @@ import { import {AttachmentURLType, URLType} from '@fluxer/schema/src/primitives/UrlValidators'; import {z} from 'zod'; +const RICH_EMBED_AUTHOR_NAME_MAX_LENGTH = 256 as const; +const RICH_EMBED_MEDIA_DESCRIPTION_MAX_LENGTH = 4096 as const; +export const RICH_EMBED_FOOTER_TEXT_MAX_LENGTH = 2048 as const; +const RICH_EMBED_FIELD_NAME_MAX_LENGTH = 256 as const; +export const RICH_EMBED_FIELD_VALUE_MAX_LENGTH = 1024 as const; +export const RICH_EMBED_TITLE_MAX_LENGTH = 256 as const; +export const RICH_EMBED_DESCRIPTION_MAX_LENGTH = 4096 as const; +const RICH_EMBED_FIELDS_MAX = 25 as const; + function omitEmbedObjectWithoutRequiredField(value: unknown, requiredField: string): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) { return value; @@ -38,7 +47,7 @@ function omitEmptyString(value: unknown): unknown { } const RichEmbedAuthorRequestShape = z.object({ - name: createStringType().describe('Name of the embed author'), + name: createStringType(1, RICH_EMBED_AUTHOR_NAME_MAX_LENGTH).describe('Name of the embed author'), url: URLType.nullish().describe('URL to link from the author name'), icon_url: URLType.nullish().describe('URL of the author icon'), }); @@ -51,7 +60,9 @@ export type RichEmbedAuthorRequest = z.infer; export const RichEmbedMediaRequestShape = z.object({ url: AttachmentURLType.describe('URL of the media (image, video, etc.)'), - description: createStringType(1, 4096).nullish().describe('Alt text description of the media'), + description: createStringType(1, RICH_EMBED_MEDIA_DESCRIPTION_MAX_LENGTH) + .nullish() + .describe('Alt text description of the media'), }); export const RichEmbedMediaRequest = z.preprocess( (value) => omitEmbedObjectWithoutRequiredField(value, 'url'), @@ -61,7 +72,9 @@ export const RichEmbedMediaRequest = z.preprocess( export type RichEmbedMediaRequest = z.infer; const RichEmbedFooterRequestShape = z.object({ - text: createStringType(1, 2048).describe('Footer text (1-2048 characters)'), + text: createStringType(1, RICH_EMBED_FOOTER_TEXT_MAX_LENGTH).describe( + `Footer text (1-${RICH_EMBED_FOOTER_TEXT_MAX_LENGTH} characters)`, + ), icon_url: URLType.nullish().describe('URL of the footer icon'), }); export const RichEmbedFooterRequest = z.preprocess( @@ -72,24 +85,32 @@ export const RichEmbedFooterRequest = z.preprocess( export type RichEmbedFooterRequest = z.infer; const RichEmbedFieldRequest = z.object({ - name: createStringType().describe('Name of the field'), - value: createStringType(0, 1024).describe('Value of the field (0-1024 characters)'), + name: createStringType(1, RICH_EMBED_FIELD_NAME_MAX_LENGTH).describe('Name of the field'), + value: createStringType(0, RICH_EMBED_FIELD_VALUE_MAX_LENGTH).describe( + `Value of the field (0-${RICH_EMBED_FIELD_VALUE_MAX_LENGTH} characters)`, + ), inline: z.boolean().default(false).describe('Whether the field should display inline'), }); export const RichEmbedRequest = z.object({ url: URLType.nullish().describe('URL of the embed'), - title: createStringType(0, 256).nullish().describe('Title of the embed (0-256 characters)'), + title: createStringType(0, RICH_EMBED_TITLE_MAX_LENGTH) + .nullish() + .describe(`Title of the embed (0-${RICH_EMBED_TITLE_MAX_LENGTH} characters)`), color: ColorType.nullish().describe('Color code of the embed (hex integer)'), timestamp: DateTimeType.nullish().describe('ISO8601 timestamp for the embed'), description: z - .preprocess(omitEmptyString, createStringType(1, 4096).nullish()) - .describe('Description of the embed (1-4096 characters)'), + .preprocess(omitEmptyString, createStringType(1, RICH_EMBED_DESCRIPTION_MAX_LENGTH).nullish()) + .describe(`Description of the embed (1-${RICH_EMBED_DESCRIPTION_MAX_LENGTH} characters)`), author: RichEmbedAuthorRequest.nullish().describe('Author information'), image: RichEmbedMediaRequest.nullish().describe('Image to display in the embed'), thumbnail: RichEmbedMediaRequest.nullish().describe('Thumbnail image for the embed'), footer: RichEmbedFooterRequest.nullish().describe('Footer information'), - fields: z.array(RichEmbedFieldRequest).max(25).nullish().describe('Array of field objects (max 25)'), + fields: z + .array(RichEmbedFieldRequest) + .max(RICH_EMBED_FIELDS_MAX) + .nullish() + .describe(`Array of field objects (max ${RICH_EMBED_FIELDS_MAX})`), }); export type RichEmbedRequest = z.infer; diff --git a/packages/schema/src/domains/webhook/InstatusWebhookSchemas.ts b/packages/schema/src/domains/webhook/InstatusWebhookSchemas.ts new file mode 100644 index 000000000..226854787 --- /dev/null +++ b/packages/schema/src/domains/webhook/InstatusWebhookSchemas.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {z} from 'zod'; + +const InstatusMeta = z.object({ + unsubscribe: createStringType(0, 2048).nullish(), + documentation: createStringType(0, 2048).nullish(), +}); + +const InstatusPage = z.object({ + id: createStringType(0, 256).nullish(), + status_indicator: createStringType(0, 256).nullish(), + status_description: createStringType(0, 1024).nullish(), + url: createStringType(0, 2048).nullish(), +}); + +const InstatusAffectedComponent = z.object({ + id: createStringType(0, 256).nullish(), + name: createStringType(0, 1024).nullish(), + status: createStringType(0, 256).nullish(), +}); + +const InstatusComponentUpdateComponent = InstatusAffectedComponent.extend({ + created_at: createStringType(0, 64).nullish(), +}); + +const InstatusIncidentIncidentUpdate = z.object({ + id: createStringType(0, 256).nullish(), + incident_id: createStringType(0, 256).nullish(), + markdown: createStringType(0, 65536).nullish(), + status: createStringType(0, 256).nullish(), + created_at: createStringType(0, 64).nullish(), + updated_at: createStringType(0, 64).nullish(), +}); + +const InstatusIncident = z.object({ + id: createStringType(0, 256).nullish(), + name: createStringType(0, 1024).nullish(), + url: createStringType(0, 2048).nullish(), + status: createStringType(0, 256).nullish(), + backfilled: z.boolean().nullish(), + created_at: createStringType(0, 64).nullish(), + updated_at: createStringType(0, 64).nullish(), + resolved_at: createStringType(0, 64).nullish(), + incident_updates: z.array(InstatusIncidentIncidentUpdate).nullish(), + /** + * @remarks Undocumented. + */ + affected_components: z.array(InstatusAffectedComponent).nullish(), +}); + +const InstatusMaintenanceMaintenanceUpdate = z.object({ + id: createStringType(0, 256).nullish(), + maintenance_id: createStringType(0, 256).nullish(), + markdown: createStringType(0, 65536).nullish(), + created_at: createStringType(0, 64).nullish(), + updated_at: createStringType(0, 64).nullish(), +}); + +const InstatusMaintenance = z.object({ + id: createStringType(0, 256).nullish(), + name: createStringType(0, 1024).nullish(), + url: createStringType(0, 2048).nullish(), + status: createStringType(0, 256).nullish(), + maintenance_start_date: createStringType(0, 64).nullish(), + maintenance_end_date: createStringType(0, 64).nullish(), + backfilled: z.boolean().nullish(), + created_at: createStringType(0, 64).nullish(), + updated_at: createStringType(0, 64).nullish(), + resolved_at: createStringType(0, 64).nullish(), + maintenance_updates: z.array(InstatusMaintenanceMaintenanceUpdate).nullish(), + affected_components: z.array(InstatusAffectedComponent).nullish(), +}); + +const InstatusComponentUpdate = z.object({ + created_at: createStringType(0, 64).nullish(), + new_status: createStringType(0, 256).nullish(), + component_id: createStringType(0, 256).nullish(), +}); + +export const InstatusWebhook = z.object({ + meta: InstatusMeta.nullish(), + page: InstatusPage.nullish(), + incident: InstatusIncident.nullish(), + maintenance: InstatusMaintenance.nullish(), + component_update: InstatusComponentUpdate.nullish(), + component: InstatusComponentUpdateComponent.nullish(), +}); + +export type InstatusWebhook = z.infer;