Add feature to filter notifications based upon filters

This commit is contained in:
Will Hunt 2021-04-11 18:09:49 +01:00
parent 1780778035
commit 6fccf02f94
5 changed files with 195 additions and 25 deletions

View File

@ -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

View File

@ -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<AdminAccountData>(
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<AdminAccountData>(
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) => {

View File

@ -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 :|
}

View File

@ -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<string>;
public readonly forInvites: Set<string>;
public filters: Record<string, FilterContent>;
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;
}
}

View File

@ -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") {