From 6fccf02f94dc6ed99ced6b5599b4ce44a3487354 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Sun, 11 Apr 2021 18:09:49 +0100 Subject: [PATCH] Add feature to filter notifications based upon filters --- src/AdminRoom.ts | 62 +++++++++++++++++++++++-- src/GithubBridge.ts | 49 +++++++++++++------- src/LogWrapper.ts | 8 +++- src/NotificationFilters.ts | 87 +++++++++++++++++++++++++++++++++++ src/NotificationsProcessor.ts | 14 ++++-- 5 files changed, 195 insertions(+), 25 deletions(-) create mode 100644 src/NotificationFilters.ts diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 7aa5d281..e4140290 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -17,6 +17,7 @@ import { MatrixMessageContent } from "./MatrixEvent"; import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetInterface"; import { Endpoints } from "@octokit/types"; import { ProjectsListResponseData } from "./Github/Types"; +import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; @@ -45,21 +46,24 @@ export interface AdminAccountData { } } } - } + export class AdminRoom extends EventEmitter { public static helpMessage: MatrixMessageContent; private widgetAccessToken = `abcdef`; static botCommands: BotCommands; private pendingOAuthState: string|null = null; + public readonly notifFilter: NotifFilter; constructor(public readonly roomId: string, public readonly data: AdminAccountData, + notifContent: NotificationFilterStateContent, private botIntent: Intent, private tokenStore: UserTokenStore, private config: BridgeConfig) { super(); + this.notifFilter = new NotifFilter(notifContent); // TODO: Move this this.backfillAccessToken(); } @@ -408,8 +412,7 @@ export class AdminRoom extends EventEmitter { } @botCommand("gitlab notifications toggle", "Toggle enabling/disabling GitHub notifications in this room", ["instanceName"]) - // @ts-ignore - property is used - private async setGitLabNotificationsStateToggle(instanceName: string) { + public async setGitLabNotificationsStateToggle(instanceName: string) { if (!this.config.gitlab) { return this.sendNotice("The bridge is not configured with GitLab support"); } @@ -437,7 +440,54 @@ export class AdminRoom extends EventEmitter { }, }; }); - await this.sendNotice(`${newValue ? "En" : "Dis"}abled GitLab notifications for ${instanceName}`); + return this.sendNotice(`${newValue ? "En" : "Dis"}abled GitLab notifications for ${instanceName}`); + } + + @botCommand("filters list", "List your saved filters") + public async getFilters() { + if (this.notifFilter.empty) { + return this.sendNotice("You do not currently have any filters"); + } + const filterText = Object.entries(this.notifFilter.filters).map(([name, value]) => { + const userText = value.users.length ? `users: ${value.users.join("|")}` : ''; + const reposText = value.repos.length ? `users: ${value.repos.join("|")}` : ''; + const orgsText = value.orgs.length ? `users: ${value.orgs.join("|")}` : ''; + return `${name}: ${userText} ${reposText} ${orgsText}` + }).join("\n"); + const enabledForInvites = [...this.notifFilter.forInvites].join(', '); + const enabledForNotifications = [...this.notifFilter.forNotifications].join(', '); + return this.sendNotice(`Your filters:\n ${filterText}\nEnabled for automatic room invites: ${enabledForInvites}\nEnabled for notifications: ${enabledForNotifications}`); + } + + @botCommand("filters set", "List your saved filters", ["name", "...parameters"]) + public async setFilter(name: string, ...parameters: string[]) { + const orgs = parameters.filter(param => param.toLowerCase().startsWith("orgs:")).map(param => param.toLowerCase().substring("orgs:".length).split(",")).flat(); + const users = parameters.filter(param => param.toLowerCase().startsWith("users:")).map(param => param.toLowerCase().substring("users:".length).split(",")).flat(); + const repos = parameters.filter(param => param.toLowerCase().startsWith("repos:")).map(param => param.toLowerCase().substring("repos:".length).split(",")).flat(); + if (orgs.length + users.length + repos.length === 0) { + return this.sendNotice("You must specify some filter options like 'orgs:matrix-org,half-shot', 'users:Half-Shot' or 'repos:matrix-github'"); + } + this.notifFilter.setFilter(name, { + orgs, + users, + repos, + }); + await this.botIntent.underlyingClient.sendStateEvent(this.roomId, NotifFilter.StateType, "", this.notifFilter.getStateContent()); + return this.sendNotice(`Stored new filter "${name}". You can now apply the filter by saying 'filters notifications toggle $name'`); + } + + @botCommand("filters notifications toggle", "List your saved filters", ["name"]) + public async setFiltersNotificationsToggle(name: string) { + if (!this.notifFilter.filters[name]) { + return this.sendNotice(`Filter "${name}" doesn't exist'`); + } + if (this.notifFilter.forNotifications.has(name)) { + this.notifFilter.forNotifications.delete(name); + return this.sendNotice(`Filter "${name}" disabled for notifications`); + } else { + this.notifFilter.forNotifications.add(name); + return this.sendNotice(`Filter "${name}" enabled for notifications`); + } } private async saveAccountData(updateFn: (record: AdminAccountData) => AdminAccountData) { @@ -540,6 +590,10 @@ export class AdminRoom extends EventEmitter { log.info(`No widget access token for ${this.roomId}`); } } + + public toString() { + return `AdminRoom(${this.roomId}, ${this.userId})`; + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/GithubBridge.ts b/src/GithubBridge.ts index d7ed0399..310444d2 100644 --- a/src/GithubBridge.ts +++ b/src/GithubBridge.ts @@ -27,6 +27,7 @@ import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types" import { GitLabClient } from "./Gitlab/Client"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { ProjectsGetResponseData } from "./Github/Types"; +import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; const log = new LogWrapper("GithubBridge"); @@ -413,21 +414,35 @@ export class GithubBridge { log.error(`Unable to create connection for ${roomId}`, ex); continue; } - this.connections.push(...connections); - if (connections.length === 0) { - // TODO: Refactor this to be a connection - try { - const accountData = await this.as.botIntent.underlyingClient.getRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, - ); - const adminRoom = await this.setupAdminRoom(roomId, accountData); - // Call this on startup to set the state - await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); - } catch (ex) { - log.debug(`Room ${roomId} has no connections and is not an admin room`); - } - } else { + if (this.connections.length) { log.info(`Room ${roomId} is connected to: ${connections.join(',')}`); + this.connections.push(...connections); + continue; + } + // TODO: Refactor this to be a connection + try { + const accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, roomId, + ); + if (!accountData) { + log.debug(`Room ${roomId} has no connections and is not an admin room`); + continue; + } + + let notifContent; + try { + notifContent = await this.as.botIntent.underlyingClient.getRoomStateEvent( + roomId, NotifFilter.StateType, "", + ); + } catch (ex) { + // No state yet + } + const adminRoom = await this.setupAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent()); + // Call this on startup to set the state + await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); + log.info(`Room ${roomId} is connected to: ${adminRoom.toString()}`); + } catch (ex) { + log.error(`Failed to setup admin room ${roomId}:`, ex); } } if (this.config.widgets) { @@ -449,7 +464,7 @@ export class GithubBridge { } await retry(() => this.as.botIntent.joinRoom(roomId), 5); if (event.content.is_direct) { - const room = await this.setupAdminRoom(roomId, {admin_user: event.sender}); + const room = await this.setupAdminRoom(roomId, {admin_user: event.sender}, NotifFilter.getDefaultContent()); await this.as.botIntent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.data, ); @@ -673,9 +688,9 @@ export class GithubBridge { } - private async setupAdminRoom(roomId: string, accountData: AdminAccountData) { + private async setupAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) { const adminRoom = new AdminRoom( - roomId, accountData, this.as.botIntent, this.tokenStore, this.config, + roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config, ); adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { diff --git a/src/LogWrapper.ts b/src/LogWrapper.ts index f21f3de5..5b9437aa 100644 --- a/src/LogWrapper.ts +++ b/src/LogWrapper.ts @@ -45,10 +45,16 @@ export default class LogWrapper { log.info(getMessageString(messageOrObject), { module }); }, warn: (module: string, ...messageOrObject: MsgType[]) => { + const error = messageOrObject[0].error || messageOrObject[1].body?.error; + if (error === "Room account data not found") { + log.debug(getMessageString(messageOrObject), { module }); + return; // This is just noise :| + } log.warn(getMessageString(messageOrObject), { module }); }, error: (module: string, ...messageOrObject: MsgType[]) => { - if (typeof messageOrObject[0] === "object" && messageOrObject[0].error === "Room account data not found") { + const error = messageOrObject[0].error || messageOrObject[1]?.body?.error; + if (error === "Room account data not found") { log.debug(getMessageString(messageOrObject), { module }); return; // This is just noise :| } diff --git a/src/NotificationFilters.ts b/src/NotificationFilters.ts new file mode 100644 index 00000000..c763ca2d --- /dev/null +++ b/src/NotificationFilters.ts @@ -0,0 +1,87 @@ + +interface FilterContent { + users: string[]; + repos: string[]; + orgs: string[]; +} + +export interface NotificationFilterStateContent { + filters: { + [name: string]: FilterContent; + }; + forNotifications: string[]; + forInvites: string[]; +} + +/** + * A notification filter is a set of keys that define what should be sent to the user. + */ +export class NotifFilter { + static readonly StateType = "uk.half-shot.matrix-github.notif-filter" + + static getDefaultContent(): NotificationFilterStateContent { + return { + filters: {}, + forNotifications: [], + forInvites: [], + } + } + + public readonly forNotifications: Set; + public readonly forInvites: Set; + public filters: Record; + constructor(stateContent: NotificationFilterStateContent) { + this.forNotifications = new Set(stateContent.forNotifications); + this.forInvites = new Set(stateContent.forInvites); + this.filters = stateContent.filters; + } + + public get empty() { + return Object.values(this.filters).length === 0; + } + + public getStateContent(): NotificationFilterStateContent { + return { + filters: this.filters, + forInvites: [...this.forInvites], + forNotifications: [...this.forNotifications], + }; + } + + public shouldInviteToRoom(user: string, repo: string, org: string): boolean { + return false; + } + + public shouldSendNotification(user?: string, repo?: string, org?: string): boolean { + if (this.forNotifications.size === 0) { + // Default on. + return true; + } + for (const filterName of this.forNotifications) { + const filter = this.filters[filterName]; + if (!filter) { + // Filter with this name exists. + continue; + } + if (user && filter.users.includes(user.toLowerCase())) { + // We have a user in this notif and we are filtering on users. + return true; + } + if (repo && filter.repos.includes(repo.toLowerCase())) { + // We have a repo in this notif and we are filtering on repos. + return true; + } + if (org && filter.orgs.includes(org.toLowerCase())) { + // We have an org in this notif and we are filtering on orgs. + return true; + } + // None of the filters matched, so exclude the result. + return false; + } + return false; + } + + public setFilter(name: string, filter: FilterContent) { + this.filters[name] = filter; + } +} \ No newline at end of file diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index d82468ab..24bfbc65 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -8,9 +8,10 @@ import { FormatUtil } from "./FormatUtil"; import { PullGetResponseData, IssuesGetResponseData, PullsListRequestedReviewersResponseData, PullsListReviewsResponseData, IssuesGetCommentResponseData } from "./Github/Types"; import { GitHubUserNotification } from "./Github/Types"; import { components } from "@octokit/openapi-types/dist-types/generated/types"; +import { NotifFilter } from "./NotificationFilters"; -const log = new LogWrapper("GithubBridge"); +const log = new LogWrapper("NotificationProcessor"); const md = new markdown(); export interface IssueDiff { @@ -110,7 +111,7 @@ export class NotificationProcessor { for (const event of msg.events) { const isIssueOrPR = event.subject.type === "Issue" || event.subject.type === "PullRequest"; try { - await this.handleUserNotification(msg.roomId, event); + await this.handleUserNotification(msg.roomId, event, adminRoom.notifFilter); if (isIssueOrPR && event.subject.url_data) { const issueNumber = event.subject.url_data.number.toString(); await this.storage.setGithubIssue( @@ -251,8 +252,15 @@ export class NotificationProcessor { return this.matrixSender.sendMatrixMessage(roomId, body); } - private async handleUserNotification(roomId: string, notif: GitHubUserNotification) { + private async handleUserNotification(roomId: string, notif: GitHubUserNotification, filter: NotifFilter) { log.info("New notification event:", notif); + if (!filter.shouldSendNotification( + notif.subject.latest_comment_url_data?.user?.login, + notif.repository.full_name, + notif.repository.owner?.login)) { + log.debug(`Dropping notification because user is filtering it out`) + return; + } if (notif.reason === "security_alert") { return this.matrixSender.sendMatrixMessage(roomId, this.formatSecurityAlert(notif)); } else if (notif.subject.type === "Issue" || notif.subject.type === "PullRequest") {