-
Notifications
You must be signed in to change notification settings - Fork 72
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
base: develop
Are you sure you want to change the base?
Changes from all commits
64495fb
07f85cf
4e54d43
40c3b9e
d57ff25
4945927
3977a6b
125b7be
9e99a35
edc2e2c
3118203
21534de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add `ServiceRoom` component. |
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; | ||
/** | ||
* 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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
|
||
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"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be named |
||
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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can |
||
throw ex; | ||
} | ||
Comment on lines
+102
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about if (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; | ||
} | ||
} |
There was a problem hiding this comment.
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.