diff --git a/README.md b/README.md index 80674c1e..00a18c8f 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,10 @@ in the options given to the bridge (simply `{}` (empty object)). By default, use will be copied on upgrade. Upgrade events will also be consumed by the bridge, and will not be emitted by `onEvent`. For more information, see the docs. +## `ServiceRoom` +Allows bridges to report status notices to a room for bridge admins to handle or +admin interfaces to render. + ## Data Models * `MatrixRoom` - A representation of a matrix room. diff --git a/changelog.d/408.feature b/changelog.d/408.feature new file mode 100644 index 00000000..7dfa410c --- /dev/null +++ b/changelog.d/408.feature @@ -0,0 +1 @@ +Add `ServiceRoom` component. \ No newline at end of file diff --git a/src/components/intent.ts b/src/components/intent.ts index 86efa3ae..a54d904a 100644 --- a/src/components/intent.ts +++ b/src/components/intent.ts @@ -94,7 +94,7 @@ export class Intent { private _requestCaches: { profile: ClientRequestCache, - roomstate: ClientRequestCache, + roomstate: ClientRequestCache, event: ClientRequestCache } protected opts: { @@ -454,7 +454,7 @@ export class Intent { * @param skey The state key * @param content The event content */ - public async sendStateEvent(roomId: string, type: string, skey: string, content: Record + public async sendStateEvent(roomId: string, type: string, skey: string, content: unknown // eslint-disable-next-line camelcase ): Promise<{event_id: string}> { return this._joinGuard(roomId, async() => { @@ -486,7 +486,7 @@ export class Intent { * @param useCache Should the request attempt to lookup * state from the cache. */ - public async roomState(roomId: string, useCache=false) { + public async roomState(roomId: string, useCache=false): Promise { await this._ensureJoined(roomId); if (useCache) { return this._requestCaches.roomstate.get(roomId); diff --git a/src/components/service-room.ts b/src/components/service-room.ts new file mode 100644 index 00000000..61a6940f --- /dev/null +++ b/src/components/service-room.ts @@ -0,0 +1,167 @@ +import { MatrixClient } from "matrix-bot-sdk"; + +export enum ServiceNotificationNoticeCode { + Unknown = "UNKNOWN", + Blocked = "BLOCKED", + RemoteServiceOutage = "REMOTE_SERVICE_OUTAGE", + MatrixServiceOutage = "MATRIX_SERVICE_OUTAGE", +} + +export enum ServiceNotificationServerity { + /** + * Just information for the administrator, usually no action required. + */ + Infomational = "info", + /** + * Something the administrator should know about, might require an explicit notification. + */ + Warning = "warning", + /** + * A serious issue has occured with the bridge, action needed. + */ + Error = "error", + /** + * The bridge cannot function, action urgently needed. + */ + Critical = "critical" +} + + +export interface ServiceRoomOpts { + /** + * The roomId to send notices to. + */ + roomId: string; + /** + * The minimum time allowed before + * a new notice with the same ID can be sent (to avoid room spam). + * Defaults to a hour. + */ + minimumUpdatePeriodMs?: number; + /** + * The prefix to use in state keys to uniquely namespace the bridge. + */ + bridgeStateKeyPrefix: string; + /** + * Any metadata to be included in all notice events. + */ + metadata: Record +} + +export interface NotificationEventContent { + message: string; + code: ServiceNotificationNoticeCode|string, + // eslint-disable-next-line camelcase + notice_id: string, + metadata: Record; + severity: ServiceNotificationServerity; + "org.matrix.msc1767.text": string, +} + +interface ResolvedEventContent { + resolved: boolean; +} + +const STATE_KEY_TYPE = "org.matrix.service-notice"; +const DEFAULT_UPDATE_TIME_MS = 1000 * 60 * 60; + +/** + * The service room component allows bridges to report service issues to an upstream service or user. + */ +export class ServiceRoom { + + /** + * The last time a given noticeId was sent. This is reset when the notice is resolved. + */ + private readonly lastNoticeTime = new Map(); + + /** + * A set of noticeIDs which we know are already resolved (and therefore can skip requests to the homeserver) + */ + private readonly resolvedNotices = new Set(); + constructor(private readonly opts: ServiceRoomOpts, private readonly client: MatrixClient) { } + + private getStateKey(noticeId: string) { + return `${this.opts.bridgeStateKeyPrefix}_${noticeId}`; + } + + /** + * Get an existing notice. + * @param noticeId The ID of the notice. + * @returns The notice content, or null if not found. + */ + public async getServiceNotification(noticeId: string): Promise { + try { + return this.client.getRoomStateEvent( + this.opts.roomId, + STATE_KEY_TYPE, + this.getStateKey(noticeId), + ); + } + catch (ex) { + if (ex.body.errcode !== "M_NOT_FOUND") { + throw ex; + } + return null; + } + } + + /** + * Send a service notice to a room. Any existing notices are automatically squashed. + * @param message A human readable message for a user to potentially action. + * @param severity The severity of the notice. + * @param noticeId A unique ID to describe this notice. Subsequent updates to the notice should use the same string. + * @param code A optional machine readable code. + */ + public async sendServiceNotice( + message: string, + severity: ServiceNotificationServerity, + noticeId: string, + code: ServiceNotificationNoticeCode|string = ServiceNotificationNoticeCode.Unknown): Promise { + if (Date.now() - (this.lastNoticeTime.get(noticeId) ?? 0) <= + (this.opts.minimumUpdatePeriodMs ?? DEFAULT_UPDATE_TIME_MS)) { + return; + } + const content: NotificationEventContent = { + message, + severity, + notice_id: noticeId, + metadata: this.opts.metadata, + code, + "org.matrix.msc1767.text": `Notice (severity: ${severity}): ${message}` + }; + this.resolvedNotices.delete(noticeId); + await this.client.sendStateEvent( + this.opts.roomId, + STATE_KEY_TYPE, + this.getStateKey(noticeId), + content + ); + this.lastNoticeTime.set(noticeId, Date.now()); + } + + /** + * Resolve a previous notice to say that the specific issue has been resolved. + * @param noticeId The noticeId to resolve. + * @returns `true` if the notice exists and was resolved, + * `false` if the notice did not exist or was already resolved. + */ + public async clearServiceNotice(noticeId: string): Promise { + const serviceNotice = await this.getServiceNotification(noticeId); + if (!serviceNotice || 'resolved' in serviceNotice) { + return false; + } + await this.client.sendStateEvent( + this.opts.roomId, + STATE_KEY_TYPE, + this.getStateKey(noticeId), + { + resolved: true, + metadata: this.opts.metadata + } + ); + this.lastNoticeTime.delete(noticeId); + this.resolvedNotices.add(noticeId); + return true; + } +} diff --git a/src/index.ts b/src/index.ts index 10fdfb2c..eb7c807c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export * from "./components/user-activity-store"; export * from "./components/room-bridge-store"; export * from "./components/event-bridge-store"; + // Models export * from "./models/rooms/matrix"; export * from "./models/rooms/remote"; @@ -58,6 +59,7 @@ export * from "./components/event-types"; export * from "./components/bridge-info-state"; export * from "./components/user-activity"; export * from "./components/bridge-blocker"; +export * from "./components/service-room"; export * from "./utils/package-info"; export * from "./utils/matrix-host-resolver"; diff --git a/src/utils/promiseutil.ts b/src/utils/promiseutil.ts index 8873f3ee..0520264f 100644 --- a/src/utils/promiseutil.ts +++ b/src/utils/promiseutil.ts @@ -34,6 +34,6 @@ export function defer(): Defer { }; } -export function delay(delayMs: number) { +export function delay(delayMs: number): Promise { return new Promise((r) => setTimeout(r, delayMs)); }