feat: Instatus webhooks (#1349)

Co-authored-by: Hampus <hampus@fluxer.app>
This commit is contained in:
Jiralite
2026-07-09 22:06:56 +02:00
committed by GitHub
co-authored by Hampus
parent 8dd230ea2f
commit dec7b63d07
14 changed files with 1300 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+234
View File
@@ -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": {
@@ -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;
+8
View File
@@ -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;
}
@@ -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(),
@@ -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<void> {
await this.webhookService.executeInstatusWebhook({
webhookId: params.webhookId,
token: params.token,
data: params.data,
requestCache: params.requestCache,
});
}
async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise<void> {
await this.webhookService.executeWebhook({
webhookId: params.webhookId,
+29 -9
View File
@@ -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<WebhookMessageRequest, 'attachments'> {
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<void> {
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<string | null> {
return this.getStaticWebhookAvatar({
webhookId,
provider: 'github',
assetFileName: 'github.webp',
});
return this.getStaticWebhookAvatar({webhookId, provider: 'github'});
}
private async getInstatusWebhookAvatar(webhookId: WebhookID): Promise<string | null> {
return this.getStaticWebhookAvatar({webhookId, provider: 'instatus'});
}
private async getStaticWebhookAvatar({
webhookId,
provider,
assetFileName,
}: {
webhookId: WebhookID;
provider: 'github';
assetFileName: 'github.webp';
provider: 'github' | 'instatus';
}): Promise<string | null> {
const cacheKey = `webhook:${webhookId}:avatar:${provider}`;
const avatarCache = await this.cacheService.get<string | null>(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,
@@ -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(`<t:${startUnix}:F>-<t:${endUnix}:F>`);
expect(windowEntry?.inline).toBe(false);
});
});
});
@@ -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);
});
});
});
@@ -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<RichEmbedRequest['fields']>[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<string, string> = {
// 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<string, number> = {
// 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<UpdateLike> | 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<ComponentLike> | null | undefined): EmbedField | undefined {
if (!components || components.length === 0) return undefined;
const parts: Array<string> = [];
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 ? `<t:${Math.floor(date.getTime() / 1000)}:F>` : 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<string> = [];
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<EmbedField>, entries: ReadonlyArray<EmbedField | undefined>): 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<EmbedField> = [];
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<EmbedField> = [];
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;
}
@@ -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<SlackWebhookRequest['attachments']>[number];
type SlackAttachmentField = NonNullable<SlackAttachment['fields']>[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})$/);
-1
View File
@@ -64,7 +64,6 @@ impl RouterService for UnfurlRouter {
url,
nsfw_mode,
bypass_cache,
cache_only: _,
..
} => {
if *bypass_cache {
@@ -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<typeof RichEmbedAuthorRequest>;
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<typeof RichEmbedMediaRequest>;
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<typeof RichEmbedFooterRequest>;
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<typeof RichEmbedRequest>;
@@ -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<typeof InstatusWebhook>;