Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Service Room component #408

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/408.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `ServiceRoom` component.
6 changes: 3 additions & 3 deletions src/components/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class Intent {

private _requestCaches: {
profile: ClientRequestCache<MatrixProfileInfo, [string, UserProfileKeys]>,
roomstate: ClientRequestCache<unknown, []>,
roomstate: ClientRequestCache<any[], []>,
event: ClientRequestCache<unknown, [string, string]>
}
protected opts: {
Expand Down Expand Up @@ -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<string, unknown>
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() => {
Expand Down Expand Up @@ -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<any[]> {
await this._ensureJoined(roomId);
if (useCache) {
return this._requestCaches.roomstate.get(roomId);
Expand Down
167 changes: 167 additions & 0 deletions src/components/service-room.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about making this non-optional and setting it to a default value in the constructor?

This would also ensure that callers won't have to handle falling back to a default value when checking this.

/**
* 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<string, unknown>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it help to define a type alias for this? It could be shared by both this and NotificationEventContent.metadata.

}

export interface NotificationEventContent {
message: string;
code: ServiceNotificationNoticeCode|string,
// eslint-disable-next-line camelcase
notice_id: string,
metadata: Record<string, unknown>;
severity: ServiceNotificationServerity;
"org.matrix.msc1767.text": string,
}

interface ResolvedEventContent {
resolved: boolean;
}

const STATE_KEY_TYPE = "org.matrix.service-notice";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be named EVENT_TYPE instead?

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<string, number>();

/**
* A set of noticeIDs which we know are already resolved (and therefore can skip requests to the homeserver)
*/
private readonly resolvedNotices = new Set<string>();
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<NotificationEventContent|ResolvedEventContent|null> {
try {
return this.client.getRoomStateEvent(
this.opts.roomId,
STATE_KEY_TYPE,
this.getStateKey(noticeId),
);
}
catch (ex) {
if (ex.body.errcode !== "M_NOT_FOUND") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can getRoomStateEvent ever throw an object without a body?

throw ex;
}
Comment on lines +102 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spaces -> tabs

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<void> {
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<boolean> {
const serviceNotice = await this.getServiceNotification(noticeId);
if (!serviceNotice || 'resolved' in serviceNotice) {
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about if serviceNotice.resolved is false?

(The code currently prevents that from ever happening, but maybe a future change will allow it.)

}
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;
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/utils/promiseutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ export function defer<T>(): Defer<T> {
};
}

export function delay(delayMs: number) {
export function delay(delayMs: number): Promise<void> {
return new Promise((r) => setTimeout(r, delayMs));
}