mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
feat: Instatus webhooks (#1349)
Co-authored-by: Hampus <hampus@fluxer.app>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})$/);
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user