diff --git a/fluxer_api/src/api/assets/instatus.webp b/fluxer_api/src/api/assets/instatus.webp new file mode 100644 index 000000000..a9704c116 Binary files /dev/null and b/fluxer_api/src/api/assets/instatus.webp differ diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index dc0aae343..0c8d655fd 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -24437,6 +24437,75 @@ } } }, + "/webhooks/{webhook_id}/{token}/instatus": { + "post": { + "operationId": "execute_instatus_webhook", + "summary": "Execute Instatus webhook", + "tags": ["Webhooks"], + "responses": { + "204": {"description": "No Content"}, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Execute Instatus webhook"}}, + "description": "Receives and processes Instatus status page webhook events, formatting them as messages in the configured channel.", + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": {"$ref": "#/components/schemas/SnowflakeType"}, + "description": "The ID of the webhook" + }, + {"name": "token", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The token"} + ], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstatusWebhook"}}} + } + } + }, "/webhooks/{webhook_id}/{token}/messages/{message_id}": { "get": { "operationId": "get_webhook_message", @@ -37847,6 +37916,171 @@ "required": ["sender"] }, "Int64Type": {"type": "string", "format": "int64", "pattern": "^-?[0-9]+$"}, + "InstatusWebhook": { + "type": "object", + "properties": { + "meta": { + "anyOf": [ + { + "type": "object", + "properties": { + "unsubscribe": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "documentation": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + }, + {"type": "null"} + ] + }, + "page": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status_indicator": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status_description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "url": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + }, + {"type": "null"} + ] + }, + "incident": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "url": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "backfilled": {"anyOf": [{"type": "boolean"}, {"type": "null"}]}, + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "resolved_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "incident_updates": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "incident_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "markdown": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + } + }, + {"type": "null"} + ] + }, + "affected_components": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + } + }, + {"type": "null"} + ] + } + } + }, + {"type": "null"} + ] + }, + "maintenance": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "url": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "maintenance_start_date": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "maintenance_end_date": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "backfilled": {"anyOf": [{"type": "boolean"}, {"type": "null"}]}, + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "resolved_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "maintenance_updates": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "maintenance_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "markdown": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "updated_at": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + } + }, + {"type": "null"} + ] + }, + "affected_components": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + } + }, + {"type": "null"} + ] + } + } + }, + {"type": "null"} + ] + }, + "component_update": { + "anyOf": [ + { + "type": "object", + "properties": { + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "new_status": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "component_id": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + }, + {"type": "null"} + ] + }, + "component": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "name": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "created_at": {"anyOf": [{"type": "string"}, {"type": "null"}]} + } + }, + {"type": "null"} + ] + } + } + }, "WebhookMessageEditRequest": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts index 9029db8ec..197d06b66 100644 --- a/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/WebhookRateLimitConfig.ts @@ -48,4 +48,8 @@ export const WebhookRateLimitConfigs = { bucket: 'webhook:github::webhook_id', config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true}, } as RouteRateLimitConfig, + WEBHOOK_INSTATUS: { + bucket: 'webhook:instatus::webhook_id', + config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, } as const; diff --git a/fluxer_api/src/api/utils/StringUtils.ts b/fluxer_api/src/api/utils/StringUtils.ts index 150b07a84..a94326f53 100644 --- a/fluxer_api/src/api/utils/StringUtils.ts +++ b/fluxer_api/src/api/utils/StringUtils.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; import {decode} from 'html-entities'; import _ from 'lodash'; @@ -43,3 +44,10 @@ export function hasVisibleContent(value: string): boolean { export function parseString(value: string, maxLength: number) { return _.truncate(decode(value).trim(), {length: maxLength}); } + +export 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; +} diff --git a/fluxer_api/src/api/webhook/WebhookController.ts b/fluxer_api/src/api/webhook/WebhookController.ts index c3630b17b..d89f0fead 100644 --- a/fluxer_api/src/api/webhook/WebhookController.ts +++ b/fluxer_api/src/api/webhook/WebhookController.ts @@ -12,6 +12,7 @@ import { import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRequestSchemas'; import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas'; +import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas'; import { SlackWebhookRequest, WebhookCreateRequest, @@ -489,6 +490,31 @@ export function WebhookController(app: HonoApp) { return ctx.body('ok', 200); }, ); + app.post( + '/webhooks/:webhook_id/:token/instatus', + RateLimitMiddleware(RateLimitConfigs.WEBHOOK_INSTATUS), + OpenAPI({ + operationId: 'execute_instatus_webhook', + summary: 'Execute Instatus webhook', + description: + 'Receives and processes Instatus status page webhook events, formatting them as messages in the configured channel.', + responseSchema: null, + statusCode: 204, + tags: ['Webhooks'], + }), + Validator('param', WebhookIdTokenParam), + Validator('json', InstatusWebhook), + async (ctx) => { + 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;