From f2d5e498401e41f78b6b9d38385ee4d2ffd64736 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 3 Nov 2022 16:25:38 -0400 Subject: [PATCH 01/52] Add service bots config --- config.sample.yml | 11 ++++++++++- src/Config/Config.ts | 30 +++++++++++++++++++++--------- src/Config/Defaults.ts | 13 +++++++++++-- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/config.sample.yml b/config.sample.yml index 383c64763..d4b35e09b 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -100,8 +100,17 @@ passFile: bot: # (Optional) Define profile information for the bot user # - displayname: GitHub Bot + displayname: Hookshot Bot avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d +serviceBots: + # (Optional) EXPERIMENTAL Define additional bot users for specific services + # + - localpart: feeds + displayname: Feeds + avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + prefix: "!feeds" + services: + - feeds metrics: # (Optional) Prometheus metrics support # diff --git a/src/Config/Config.ts b/src/Config/Config.ts index ae251d1cf..a1095b823 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -79,7 +79,7 @@ export class BridgeConfigGitHub { @configKey("Prefix used when creating ghost users for GitHub accounts.", true) readonly userIdPrefix: string; - + @configKey("URL for enterprise deployments. Does not include /api/v3", true) private enterpriseUrl?: string; @@ -129,12 +129,12 @@ export interface BridgeConfigJiraYAML { } export class BridgeConfigJira implements BridgeConfigJiraYAML { static CLOUD_INSTANCE_NAME = "api.atlassian.com"; - + @configKey("Webhook settings for JIRA") readonly webhook: { secret: string; }; - + // To hide the undefined for now @hideKey() @configKey("URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", true) @@ -411,6 +411,14 @@ interface BridgeConfigEncryption { storagePath: string; } +export interface BridgeConfigServiceBot { + localpart: string; + displayname?: string; + avatar?: string; + prefix: string; + services: string[]; +} + export interface BridgeConfigProvisioning { bindAddress?: string; port?: number; @@ -425,6 +433,7 @@ export interface BridgeConfigMetrics { export interface BridgeConfigRoot { bot?: BridgeConfigBot; + serviceBots?: BridgeConfigServiceBot[]; bridge: BridgeConfigBridge; encryption?: BridgeConfigEncryption; figma?: BridgeConfigFigma; @@ -476,6 +485,8 @@ export class BridgeConfig { public readonly feeds?: BridgeConfigFeeds; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; + @configKey("EXPERIMENTAL Define additional bot users for specific services", true) + public readonly serviceBots?: BridgeConfigServiceBot[]; @configKey("EXPERIMENTAL support for complimentary widgets", true) public readonly widgets?: BridgeWidgetConfig; @configKey("Provisioning API for integration managers", true) @@ -511,6 +522,7 @@ export class BridgeConfig { this.provisioning = configData.provisioning; this.passFile = configData.passFile; this.bot = configData.bot; + this.serviceBots = configData.serviceBots; this.metrics = configData.metrics; this.queue = configData.queue || { monolithic: true, @@ -522,7 +534,7 @@ export class BridgeConfig { } this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets); - + // To allow DEBUG as well as debug this.logging.level = this.logging.level.toLowerCase() as "debug"|"info"|"warn"|"error"|"trace"; if (!ValidLogLevelStrings.includes(this.logging.level)) { @@ -538,7 +550,7 @@ export class BridgeConfig { }]; this.bridgePermissions = new BridgePermissions(this.permissions); - if (!configData.permissions) { + if (!configData.permissions) { log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`); } @@ -565,7 +577,7 @@ export class BridgeConfig { }); log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (configData.widgets?.port) { this.listeners.push({ resources: ['widgets'], @@ -581,7 +593,7 @@ export class BridgeConfig { }) log.warn("The `provisioning` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (this.metrics?.port) { this.listeners.push({ resources: ['metrics'], @@ -590,7 +602,7 @@ export class BridgeConfig { }) log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (configData.widgets?.port) { this.listeners.push({ resources: ['widgets'], @@ -628,7 +640,7 @@ export class BridgeConfig { const membership = await client.getJoinedRoomMembers(await client.resolveRoom(roomEntry)); membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId)); log.debug(`Found ${membership.length} users for ${roomEntry}`); - } + } } public addMemberToCache(roomId: string, userId: string) { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index d7d292b75..4eea93aab 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -11,7 +11,7 @@ export const DefaultConfig = new BridgeConfig({ url: "http://localhost:8008", mediaUrl: "http://example.com", port: 9993, - bindAddress: "127.0.0.1", + bindAddress: "127.0.0.1", }, queue: { monolithic: true, @@ -44,9 +44,18 @@ export const DefaultConfig = new BridgeConfig({ }, }, bot: { - displayname: "GitHub Bot", + displayname: "Hookshot Bot", avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d" }, + serviceBots: [ + { + localpart: "feeds", + displayname: "Feeds", + avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d", + prefix: "!feeds", + services: ["feeds"], + }, + ], github: { auth: { id: 123, From 3399c2bfcf03f0e7e87b01f236f714e3f96ff2b0 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Mon, 7 Nov 2022 12:30:14 -0500 Subject: [PATCH 02/52] Add joined rooms manager and keep track of joined rooms --- src/Bridge.ts | 149 +++++++++++++++++------------ src/Managers/JoinedRoomsManager.ts | 47 +++++++++ 2 files changed, 134 insertions(+), 62 deletions(-) create mode 100644 src/Managers/JoinedRoomsManager.ts diff --git a/src/Bridge.ts b/src/Bridge.ts index f2286bfea..b6538574a 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -13,6 +13,7 @@ import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubU import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./Jira/WebhookTypes"; import { JiraOAuthResult } from "./Jira/Types"; +import JoinedRoomsManager from "./Managers/JoinedRoomsManager"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; @@ -46,9 +47,13 @@ export class Bridge { private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; private readonly tokenStore: UserTokenStore; + // Set of user IDs for all our bot users + private readonly botUserIds = new Set(); + private readonly joinedRoomsManager = new JoinedRoomsManager(); private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); + private widgetApi?: BridgeWidgetApi; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -81,20 +86,23 @@ export class Bridge { await this.storage.connect?.(); await this.queue.connect?.(); - // Fetch all room state - let joinedRooms: string[]|undefined; - while(joinedRooms === undefined) { - try { - log.info("Connecting to homeserver and fetching joined rooms.."); - joinedRooms = await this.as.botIntent.getJoinedRooms(); - log.debug(`Bridge bot is joined to ${joinedRooms.length} rooms`); - } catch (ex) { - // This is our first interaction with the homeserver, so wait if it's not ready yet. - log.warn("Failed to connect to homeserver, retrying in 5s", ex); - await new Promise((r) => setTimeout(r, 5000)); + // Collect user IDs of all our bots + this.botUserIds.add(this.as.botUserId); + this.config.serviceBots?.forEach(b => this.botUserIds.add(this.as.getUserId(b.localpart))); + + log.info("Connecting to homeserver and fetching joined rooms..."); + + // Collect joined rooms for all our bots + for (const botUserId of this.botUserIds) { + const intent = this.as.getIntentForUserId(botUserId); + const joinedRooms = await retry(() => intent.underlyingClient.getJoinedRooms(), 3, 3000); + log.debug(`Bot "${botUserId}" is joined to ${joinedRooms.length} rooms`); + + for (const r of joinedRooms) { + this.joinedRoomsManager.addJoinedRoom(r, botUserId); } } - + await this.config.prefillMembershipCache(this.as.botClient); if (this.config.github) { @@ -124,7 +132,7 @@ export class Bridge { ); } - + if (this.config.provisioning) { const routers = []; if (this.config.jira) { @@ -265,114 +273,114 @@ export class Bridge { this.bindHandlerToQueue( "github.issues.unlabeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onIssueUnlabeled(data), ); this.bindHandlerToQueue( "github.issues.labeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onIssueLabeled(data), ); this.bindHandlerToQueue( "github.pull_request.opened", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPROpened(data), ); this.bindHandlerToQueue( "github.pull_request.closed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRClosed(data), ); this.bindHandlerToQueue( "github.pull_request.ready_for_review", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRReadyForReview(data), ); this.bindHandlerToQueue( "github.pull_request_review.submitted", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRReviewed(data), ); this.bindHandlerToQueue( "github.workflow_run.completed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onWorkflowCompleted(data), ); this.bindHandlerToQueue( "github.release.published", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onReleaseCreated(data), ); this.bindHandlerToQueue( "github.release.created", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onReleaseDrafted(data), ); this.bindHandlerToQueue( "gitlab.merge_request.open", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestOpened(data), ); this.bindHandlerToQueue( "gitlab.merge_request.close", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestClosed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.merge", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestMerged(data), ); this.bindHandlerToQueue( "gitlab.merge_request.approved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestReviewed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.unapproved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestReviewed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.update", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestUpdate(data), ); this.bindHandlerToQueue( "gitlab.release.create", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onRelease(data), ); this.bindHandlerToQueue( "gitlab.tag_push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onGitLabTagPush(data), ); this.bindHandlerToQueue( "gitlab.push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onGitLabPush(data), ); this.bindHandlerToQueue( "gitlab.wiki_page", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onWikiPageEvent(data), ); @@ -418,10 +426,10 @@ export class Bridge { this.bindHandlerToQueue( "gitlab.note.created", - (data) => { + (data) => { const iid = data.issue?.iid || data.merge_request?.iid; return [ - ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), + ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), ...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), ]}, (c, data) => c.onCommentCreated(data), @@ -429,19 +437,19 @@ export class Bridge { this.bindHandlerToQueue( "gitlab.issue.reopen", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), (c) => c.onIssueReopened(), ); this.bindHandlerToQueue( "gitlab.issue.close", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), (c) => c.onIssueClosed(), ); this.bindHandlerToQueue( "github.discussion_comment.created", - (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), + (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), (c, data) => c.onDiscussionCommentCreated(data), ); @@ -485,7 +493,7 @@ export class Bridge { } }) }); - + this.bindHandlerToQueue( "jira.issue_created", (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project), @@ -552,7 +560,7 @@ export class Bridge { }); }); - + this.queue.on("generic-webhook.event", async (msg) => { const { data, messageId } = msg; const connections = connManager.getConnectionsForGenericWebhook(data.hookId); @@ -654,7 +662,7 @@ export class Bridge { } } - await Promise.all(joinedRooms.map(async (roomId) => { + await Promise.all(this.joinedRoomsManager.getJoinedRooms().map(async (roomId) => { log.debug("Fetching state for " + roomId); try { await connManager.createConnectionsForRoomId(roomId, false); @@ -716,8 +724,8 @@ export class Bridge { const apps = this.listener.getApplicationsForResource('widgets'); if (apps.length > 1) { throw Error('You may only bind `widgets` to one listener.'); - } - new BridgeWidgetApi( + } + this.widgetApi = new BridgeWidgetApi( this.adminRooms, this.config, this.storage, @@ -725,7 +733,7 @@ export class Bridge { this.connectionManager, this.as.botIntent, ); - + } if (this.provisioningApi) { this.listener.bindResource('provisioning', this.provisioningApi.expressRouter); @@ -778,11 +786,15 @@ export class Bridge { } - private async onRoomLeave(roomId: string, event: MatrixEvent) { - if (event.state_key !== this.as.botUserId) { - // Only interested in bot leaves. + private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) { + const userId = matrixEvent.state_key; + if (!userId || !this.botUserIds.has(userId)) { + // Not for one of our bots return; } + + this.joinedRoomsManager.removeJoinedRoom(roomId, userId); + // If the bot has left the room, we want to vape all connections for that room. try { await this.connectionManager?.removeConnectionsForRoom(roomId); @@ -895,23 +907,28 @@ export class Bridge { } private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) { - if (this.as.botUserId !== matrixEvent.sender) { - // Only act on bot joins + const userId = matrixEvent.state_key; + if (!userId || !this.botUserIds.has(userId)) { + // Not for one of our bots return; } + this.joinedRoomsManager.addJoinedRoom(roomId, userId); + + const intent = this.as.getIntentForUserId(userId); + if (this.config.encryption) { // Ensure crypto is aware of all members of this room before posting any messages, // so that the bot can share room keys to all recipients first. - await this.as.botClient.crypto.onRoomJoin(roomId); + await intent.underlyingClient.crypto.onRoomJoin(roomId); } - const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + const adminAccountData = await intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (adminAccountData) { const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent()); - await this.as.botClient.setRoomAccountData( + await intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); } @@ -921,20 +938,28 @@ export class Bridge { return; } + // Recreate connections for the room + await this.connectionManager.removeConnectionsForRoom(roomId); + await this.connectionManager.createConnectionsForRoomId(roomId, true); + // Only fetch rooms we have no connections in yet. - const roomHasConnection = - this.connectionManager.isRoomConnected(roomId) || - await this.connectionManager.createConnectionsForRoomId(roomId, true); + const roomHasConnection = this.connectionManager.isRoomConnected(roomId); - // If room has connections or is an admin room, don't setup a wizard. + // If room has connections or is an admin room, don't set up a wizard. // Otherwise it's a new room if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { try { - if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) { - await this.as.botIntent.sendText(roomId, "Hello! To setup new integrations in this room, please promote me to a Moderator/Admin"); + const hasPowerLevel = await intent.underlyingClient.userHasPowerLevelFor( + intent.userId, + roomId, + "im.vector.modular.widgets", + true, + ); + if (!hasPowerLevel) { + await intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin"); } else { // Setup the widget - await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); + await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets); } } catch (ex) { log.error(`Failed to setup new widget for room`, ex); @@ -999,7 +1024,7 @@ export class Bridge { log.error(`Failed to create setup widget for ${roomId}`, ex); } } - } + } return; } @@ -1164,7 +1189,7 @@ export class Bridge { }); } } - + } private async getOrCreateAdminRoomForUser(userId: string): Promise { @@ -1215,14 +1240,14 @@ export class Bridge { const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || []; if (connection) { return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); - } + } const newConnection = await GitLabIssueConnection.createRoomForIssue( instanceName, instance, res, issueInfo.projects, this.as, - this.tokenStore, + this.tokenStore, this.commentProcessor, this.messageClient, this.config.gitlab, diff --git a/src/Managers/JoinedRoomsManager.ts b/src/Managers/JoinedRoomsManager.ts new file mode 100644 index 000000000..67be5b41a --- /dev/null +++ b/src/Managers/JoinedRoomsManager.ts @@ -0,0 +1,47 @@ +export default class JoinedRoomsManager { + // Map of room ID to set of our bot user IDs in the room + private readonly botsInRooms: Map> = new Map(); + + /** + * Get the list of room IDs where at least one bot is a member. + * @returns List of room IDs. + */ + getJoinedRooms(): string[] { + return Array.from(this.botsInRooms.keys()); + } + + /** + * Add a bot user ID to the set of bots in a room. + * @param roomId + * @param botUserId + */ + addJoinedRoom(roomId: string, botUserId: string) { + const userIds = this.botsInRooms.get(roomId) ?? new Set(); + userIds.add(botUserId); + this.botsInRooms.set(roomId, userIds); + } + + /** + * Remove a bot user ID from the set of bots in a room. + * @param roomId + * @param botUserId + */ + removeJoinedRoom(roomId: string, botUserId: string) { + const userIds = this.botsInRooms.get(roomId) ?? new Set(); + userIds.delete(botUserId); + if (userIds.size > 0) { + this.botsInRooms.set(roomId, userIds); + } else { + this.botsInRooms.delete(roomId); + } + } + + /** + * Get the list of user IDs for all bots in a room. + * @param roomId + * @returns List of user IDs for all bots in the room. + */ + getBotsInRoom(roomId: string) { + return Array.from(this.botsInRooms.get(roomId) || new Set()); + } +} From 6dbc696bb6b03e22d934629f1ba75a02f5799fdb Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Mon, 7 Nov 2022 14:35:55 -0500 Subject: [PATCH 03/52] Add bot users manager and ensure registration and profiles --- src/Bridge.ts | 66 ++++++++++++++------------ src/Managers/BotUsersManager.ts | 82 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 30 deletions(-) create mode 100644 src/Managers/BotUsersManager.ts diff --git a/src/Bridge.ts b/src/Bridge.ts index b6538574a..9fd779257 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,6 +1,7 @@ import { AdminAccountData } from "./AdminRoomCommandHandler"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; -import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent } from "matrix-bot-sdk"; +import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, IAppserviceRegistration } from "matrix-bot-sdk"; +import BotUsersManager from "./Managers/BotUsersManager"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; @@ -47,9 +48,8 @@ export class Bridge { private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; private readonly tokenStore: UserTokenStore; - // Set of user IDs for all our bot users - private readonly botUserIds = new Set(); - private readonly joinedRoomsManager = new JoinedRoomsManager(); + private readonly botUsersManager; + private readonly joinedRoomsManager; private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); @@ -62,6 +62,7 @@ export class Bridge { constructor( private config: BridgeConfig, private readonly listener: ListenerService, + private readonly registration: IAppserviceRegistration, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, ) { @@ -71,6 +72,9 @@ export class Bridge { this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); + this.joinedRoomsManager = new JoinedRoomsManager(); + this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as); + this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); } @@ -86,14 +90,36 @@ export class Bridge { await this.storage.connect?.(); await this.queue.connect?.(); - // Collect user IDs of all our bots - this.botUserIds.add(this.as.botUserId); - this.config.serviceBots?.forEach(b => this.botUserIds.add(this.as.getUserId(b.localpart))); + log.info("Ensuring bot users are set up..."); + + // Register and set profiles for all our bots + for (const botUser of this.botUsersManager.botUsers) { + // Ensure the bot is registered + const intent = this.as.getIntentForUserId(botUser.userId); + log.debug(`Ensuring '${botUser.userId}' is registered`); + await intent.ensureRegistered(); + + // Set up the bot profile + let profile; + try { + profile = await intent.underlyingClient.getUserProfile(botUser.userId); + } catch { + profile = {} + } + if (botUser.avatar && profile.avatar_url !== botUser.avatar) { + log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); + await intent.underlyingClient.setAvatarUrl(botUser.avatar); + } + if (botUser.displayname && profile.displayname !== botUser.displayname) { + log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); + await intent.underlyingClient.setDisplayName(botUser.displayname); + } + } log.info("Connecting to homeserver and fetching joined rooms..."); // Collect joined rooms for all our bots - for (const botUserId of this.botUserIds) { + for (const botUserId of this.botUsersManager.botUserIds) { const intent = this.as.getIntentForUserId(botUserId); const joinedRooms = await retry(() => intent.underlyingClient.getJoinedRooms(), 3, 3000); log.debug(`Bot "${botUserId}" is joined to ${joinedRooms.length} rooms`); @@ -642,26 +668,6 @@ export class Bridge { (c, data) => c.handleFeedError(data), ); - // Set the name and avatar of the bot - if (this.config.bot) { - // Ensure we are registered before we set a profile - await this.as.botIntent.ensureRegistered(); - let profile; - try { - profile = await this.as.botClient.getUserProfile(this.as.botUserId); - } catch { - profile = {} - } - if (this.config.bot.avatar && profile.avatar_url !== this.config.bot.avatar) { - log.info(`Setting avatar to ${this.config.bot.avatar}`); - await this.as.botClient.setAvatarUrl(this.config.bot.avatar); - } - if (this.config.bot.displayname && profile.displayname !== this.config.bot.displayname) { - log.info(`Setting displayname to ${this.config.bot.displayname}`); - await this.as.botClient.setDisplayName(this.config.bot.displayname); - } - } - await Promise.all(this.joinedRoomsManager.getJoinedRooms().map(async (roomId) => { log.debug("Fetching state for " + roomId); try { @@ -788,7 +794,7 @@ export class Bridge { private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) { const userId = matrixEvent.state_key; - if (!userId || !this.botUserIds.has(userId)) { + if (!userId || !this.botUsersManager.isBotUser(userId)) { // Not for one of our bots return; } @@ -908,7 +914,7 @@ export class Bridge { private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) { const userId = matrixEvent.state_key; - if (!userId || !this.botUserIds.has(userId)) { + if (!userId || !this.botUsersManager.isBotUser(userId)) { // Not for one of our bots return; } diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts new file mode 100644 index 000000000..44ffbb9ec --- /dev/null +++ b/src/Managers/BotUsersManager.ts @@ -0,0 +1,82 @@ +import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk"; + +import { BridgeConfig } from "../Config/Config"; + +export interface BotUser { + localpart: string; + userId: string; + avatar?: string; + displayname?: string; + prefix: string; +} + +export default class BotUsersManager { + // Map of user ID to config for all our configured bot users + private _botUsers = new Map(); + + constructor( + readonly config: BridgeConfig, + readonly registration: IAppserviceRegistration, + readonly as: Appservice, + ) { + // Default bot user + this._botUsers.set(this.as.botUserId, { + localpart: registration.sender_localpart, + userId: this.as.botUserId, + avatar: this.config.bot?.avatar, + displayname: this.config.bot?.displayname, + prefix: "!hookshot", + }); + + // Service bot users + if (this.config.serviceBots) { + this.config.serviceBots.forEach(bot => { + const userId = this.as.getUserId(bot.localpart); + this._botUsers.set(userId, { + localpart: bot.localpart, + userId: userId, + avatar: bot.avatar, + displayname: bot.displayname, + prefix: bot.prefix, + }); + }); + } + } + + /** + * Gets the configured bot users. + * + * @returns List of bot users. + */ + get botUsers(): Readonly[] { + return Array.from(this._botUsers.values()); + } + + /** + * Gets the configured bot user IDs. + * + * @returns List of bot user IDs. + */ + get botUserIds(): string[] { + return Array.from(this._botUsers.keys()); + } + + /** + * Gets a configured bot user by user ID. + * + * @param userId User ID to get. + */ + getBotUser(userId: string): Readonly | undefined { + return this._botUsers.get(userId); + } + + /** + * Checks if the given user ID belongs to a configured bot user. + * + * @param userId User ID to check. + * @returns `true` if the user ID belongs to a bot user, otherwise `false`. + */ + isBotUser(userId: string): boolean { + return this._botUsers.has(userId); + } +} From 74ae91adc91b323f278babdf93bbd03a88773227 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Mon, 14 Nov 2022 16:25:01 -0500 Subject: [PATCH 04/52] Improve joined rooms manager and set up already joined rooms --- src/Bridge.ts | 20 +++++++++----- src/Managers/JoinedRoomsManager.ts | 42 +++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 9fd779257..b8ac58955 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -668,10 +668,15 @@ export class Bridge { (c, data) => c.handleFeedError(data), ); - await Promise.all(this.joinedRoomsManager.getJoinedRooms().map(async (roomId) => { + // Set up already joined rooms + await Promise.all(this.joinedRoomsManager.joinedRooms.map(async (roomId) => { log.debug("Fetching state for " + roomId); + + const botUserId = this.joinedRoomsManager.getBotsInRoom(roomId)[0]; + const intent = this.as.getIntentForUserId(botUserId); + try { - await connManager.createConnectionsForRoomId(roomId, false); + await connManager.createConnectionsForRoomId(intent, roomId, false); } catch (ex) { log.error(`Unable to create connection for ${roomId}`, ex); return; @@ -679,11 +684,11 @@ export class Bridge { // TODO: Refactor this to be a connection try { - let accountData = await this.as.botClient.getSafeRoomAccountData( + let accountData = await intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { - accountData = await this.as.botClient.getSafeRoomAccountData( + accountData = await intent.underlyingClient.getSafeRoomAccountData( LEGACY_BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { @@ -691,18 +696,18 @@ export class Bridge { return; } else { // Upgrade the room - await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + await intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); } } let notifContent; try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.StateType, "", ); } catch (ex) { try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.LegacyStateType, "", ); } @@ -710,6 +715,7 @@ export class Bridge { // No state yet } } + // TODO Pass bot intent to set up admin room 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 }); diff --git a/src/Managers/JoinedRoomsManager.ts b/src/Managers/JoinedRoomsManager.ts index 67be5b41a..773f15b37 100644 --- a/src/Managers/JoinedRoomsManager.ts +++ b/src/Managers/JoinedRoomsManager.ts @@ -1,47 +1,63 @@ export default class JoinedRoomsManager { // Map of room ID to set of our bot user IDs in the room - private readonly botsInRooms: Map> = new Map(); + private readonly _botsInRooms: Map> = new Map(); /** - * Get the list of room IDs where at least one bot is a member. + * Gets a map of the bot users in each room. + * + * @returns Map of room IDs to the list of bot user IDs in that room. + */ + get botsInRooms(): Map { + return new Map(Array.from( + this._botsInRooms, + ([k, v]) => [k, Array.from(v)] + )); + } + + /** + * Gets the list of room IDs where at least one bot is a member. + * * @returns List of room IDs. */ - getJoinedRooms(): string[] { - return Array.from(this.botsInRooms.keys()); + get joinedRooms(): string[] { + return Array.from(this._botsInRooms.keys()); } /** - * Add a bot user ID to the set of bots in a room. + * Adds a bot user ID to the set of bots in a room. + * * @param roomId * @param botUserId */ addJoinedRoom(roomId: string, botUserId: string) { - const userIds = this.botsInRooms.get(roomId) ?? new Set(); + const userIds = this._botsInRooms.get(roomId) ?? new Set(); userIds.add(botUserId); - this.botsInRooms.set(roomId, userIds); + this._botsInRooms.set(roomId, userIds); } /** - * Remove a bot user ID from the set of bots in a room. + * Removes a bot user ID from the set of bots in a room. + * * @param roomId * @param botUserId */ removeJoinedRoom(roomId: string, botUserId: string) { - const userIds = this.botsInRooms.get(roomId) ?? new Set(); + const userIds = this._botsInRooms.get(roomId) ?? new Set(); userIds.delete(botUserId); if (userIds.size > 0) { - this.botsInRooms.set(roomId, userIds); + this._botsInRooms.set(roomId, userIds); } else { - this.botsInRooms.delete(roomId); + this._botsInRooms.delete(roomId); } } /** - * Get the list of user IDs for all bots in a room. + * Gets the list of user IDs for all bots in a room. + * * @param roomId * @returns List of user IDs for all bots in the room. */ getBotsInRoom(roomId: string) { - return Array.from(this.botsInRooms.get(roomId) || new Set()); + return Array.from(this._botsInRooms.get(roomId) || new Set()); } } From d1d16345f6aa9f2438c0220b41441b4a180716cd Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 15 Nov 2022 17:05:15 -0500 Subject: [PATCH 05/52] Handle invites with service bots --- src/Bridge.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index b8ac58955..7312d07be 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -778,20 +778,29 @@ export class Bridge { /* Do not handle invites from our users */ return; } - log.info(`Got invite roomId=${roomId} from=${event.sender} to=${event.state_key}`); - // Room joins can fail over federation - if (event.state_key !== this.as.botUserId) { - return this.as.botClient.kickUser(event.state_key, roomId, "Bridge does not support DMing ghosts"); + const invitedUserId = event.state_key; + if (!invitedUserId) { + return; + } + log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`); + + if (!this.botUsersManager.isBotUser(invitedUserId)) { + // We got an invite but it's not a configured bot user, must be for a ghost user + const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; + return client.kickUser(invitedUserId, roomId, "Bridge does not support DMing ghosts"); } + const intent = this.as.getIntentForUserId(invitedUserId); + // Don't accept invites from people who can't do anything if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return this.as.botClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot."); + return intent.underlyingClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot."); } - await retry(() => this.as.botIntent.joinRoom(roomId), 5); + // Accept the invite + await retry(() => intent.joinRoom(roomId), 5); if (event.content.is_direct) { - await this.as.botClient.setRoomAccountData( + await intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, ); } From a31960b73a87a4445284131f15bc63b22b655392 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 15 Nov 2022 17:10:50 -0500 Subject: [PATCH 06/52] Handle messages with service bots --- src/Bridge.ts | 33 ++++++++++++------- src/Config/Config.ts | 23 ++++++++++++++ src/Connections/IConnection.ts | 3 +- src/Managers/BotUsersManager.ts | 51 ++++++++++++++++++++++++------ src/Managers/JoinedRoomsManager.ts | 6 ++-- 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 7312d07be..4ca804800 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -73,7 +73,7 @@ export class Bridge { this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); this.joinedRoomsManager = new JoinedRoomsManager(); - this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as); + this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as, this.joinedRoomsManager); this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); @@ -119,13 +119,13 @@ export class Bridge { log.info("Connecting to homeserver and fetching joined rooms..."); // Collect joined rooms for all our bots - for (const botUserId of this.botUsersManager.botUserIds) { - const intent = this.as.getIntentForUserId(botUserId); + for (const botUser of this.botUsersManager.botUsers) { + const intent = this.as.getIntentForUserId(botUser.userId); const joinedRooms = await retry(() => intent.underlyingClient.getJoinedRooms(), 3, 3000); - log.debug(`Bot "${botUserId}" is joined to ${joinedRooms.length} rooms`); + log.debug(`Bot "${botUser.userId}" is joined to ${joinedRooms.length} rooms`); for (const r of joinedRooms) { - this.joinedRoomsManager.addJoinedRoom(r, botUserId); + this.joinedRoomsManager.addJoinedRoom(r, botUser.userId); } } @@ -866,13 +866,18 @@ export class Bridge { } if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) { // Divert to the setup room code if we didn't match any of these - try { - await ( - new SetupConnection( + + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + // Try each bot in the room until one handles the command + for (const botUser of botUsersInRoom) { + try { + const setupConnection = new SetupConnection( roomId, + botUser.prefix, { config: this.config, as: this.as, + intent: this.as.getIntentForUserId(botUser.userId), tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, messageClient: this.messageClient, @@ -882,10 +887,14 @@ export class Bridge { }, this.getOrCreateAdminRoomForUser.bind(this), this.connectionManager.push.bind(this.connectionManager), - ) - ).onMessageEvent(event, checkPermission); - } catch (ex) { - log.warn(`Setup connection failed to handle:`, ex); + ); + const handled = await setupConnection.onMessageEvent(event, checkPermission); + if (handled) { + break; + } + } catch (ex) { + log.warn(`Setup connection failed to handle:`, ex); + } } } return; diff --git a/src/Config/Config.ts b/src/Config/Config.ts index a1095b823..a3026c87e 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -659,6 +659,29 @@ export class BridgeConfig { return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]); } + public getEnabledServices(): string[] { + const services = []; + if (this.feeds) { + services.push("feeds"); + } + if (this.figma) { + services.push("figma"); + } + if (this.generic) { + services.push("webhooks"); + } + if (this.github) { + services.push("github"); + } + if (this.github) { + services.push("gitlab"); + } + if (this.github) { + services.push("jira"); + } + return services; + } + public getPublicConfigForService(serviceName: string): Record { let config: undefined|Record; switch (serviceName) { diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 2905a2855..9a8f45cc7 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -1,7 +1,7 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; -import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel } from "../Config/Config"; import { UserTokenStore } from "../UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; @@ -88,6 +88,7 @@ export const ConnectionDeclarations: Array = []; export interface InstantiateConnectionOpts { as: Appservice, + intent: Intent, config: BridgeConfig, tokenStore: UserTokenStore, commentProcessor: CommentProcessor, diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 44ffbb9ec..14f699030 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -1,13 +1,20 @@ import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk"; +import { Logger } from "matrix-appservice-bridge"; import { BridgeConfig } from "../Config/Config"; +import JoinedRoomsManager from "./JoinedRoomsManager"; + +const log = new Logger("BotUsersManager"); export interface BotUser { localpart: string; userId: string; avatar?: string; displayname?: string; + services: string[]; prefix: string; + // Bots with higher priority should handle a command first + priority: number; } export default class BotUsersManager { @@ -18,6 +25,7 @@ export default class BotUsersManager { readonly config: BridgeConfig, readonly registration: IAppserviceRegistration, readonly as: Appservice, + readonly joinedRoomsManager: JoinedRoomsManager, ) { // Default bot user this._botUsers.set(this.as.botUserId, { @@ -25,7 +33,10 @@ export default class BotUsersManager { userId: this.as.botUserId, avatar: this.config.bot?.avatar, displayname: this.config.bot?.displayname, + // Default bot can handle all services + services: this.config.getEnabledServices(), prefix: "!hookshot", + priority: 0, }); // Service bot users @@ -37,7 +48,10 @@ export default class BotUsersManager { userId: userId, avatar: bot.avatar, displayname: bot.displayname, + services: bot.services, prefix: bot.prefix, + // Service bots should handle commands first + priority: 1, }); }); } @@ -52,15 +66,6 @@ export default class BotUsersManager { return Array.from(this._botUsers.values()); } - /** - * Gets the configured bot user IDs. - * - * @returns List of bot user IDs. - */ - get botUserIds(): string[] { - return Array.from(this._botUsers.keys()); - } - /** * Gets a configured bot user by user ID. * @@ -79,4 +84,32 @@ export default class BotUsersManager { isBotUser(userId: string): boolean { return this._botUsers.has(userId); } + + /** + * Gets all the bot users in a room, ordered by priority. + * + * @param roomId Room ID to get bots for. + */ + getBotUsersInRoom(roomId: string): Readonly[] { + return this.joinedRoomsManager.getBotsInRoom(roomId) + .map(botUserId => this.getBotUser(botUserId)) + .filter((b): b is BotUser => b !== undefined) + .sort((a, b) => (a.priority < b.priority) ? 1 : -1) + } + + /** + * Gets a bot user in a room, optionally for a particular service. + * When a service is specified, the bot user with the highest priority which handles that service is returned. + * + * @param roomId Room ID to get a bot user for. + * @param serviceType Optional service type for the bot. + */ + getBotUserInRoom(roomId: string, serviceType?: string): Readonly | undefined { + const botUsersInRoom = this.getBotUsersInRoom(roomId); + if (serviceType) { + return botUsersInRoom.find(b => b.services.includes(serviceType)); + } else { + return botUsersInRoom[0]; + } + } } diff --git a/src/Managers/JoinedRoomsManager.ts b/src/Managers/JoinedRoomsManager.ts index 773f15b37..8b86639ba 100644 --- a/src/Managers/JoinedRoomsManager.ts +++ b/src/Managers/JoinedRoomsManager.ts @@ -29,7 +29,7 @@ export default class JoinedRoomsManager { * @param roomId * @param botUserId */ - addJoinedRoom(roomId: string, botUserId: string) { + addJoinedRoom(roomId: string, botUserId: string): void{ const userIds = this._botsInRooms.get(roomId) ?? new Set(); userIds.add(botUserId); this._botsInRooms.set(roomId, userIds); @@ -41,7 +41,7 @@ export default class JoinedRoomsManager { * @param roomId * @param botUserId */ - removeJoinedRoom(roomId: string, botUserId: string) { + removeJoinedRoom(roomId: string, botUserId: string): void { const userIds = this._botsInRooms.get(roomId) ?? new Set(); userIds.delete(botUserId); if (userIds.size > 0) { @@ -57,7 +57,7 @@ export default class JoinedRoomsManager { * @param roomId * @returns List of user IDs for all bots in the room. */ - getBotsInRoom(roomId: string) { + getBotsInRoom(roomId: string): string[] { return Array.from(this._botsInRooms.get(roomId) || new Set()); } } From f90201e1991020bda7c71a05b7ab284959ff441f Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 17 Nov 2022 15:57:34 -0500 Subject: [PATCH 07/52] Use service bots for connections --- src/Bridge.ts | 42 +++++++++++++++++++++++++------------ src/ConnectionManager.ts | 45 +++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 4ca804800..0a9ab8bd1 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -145,15 +145,23 @@ export class Bridge { await ensureFigmaWebhooks(this.config.figma, this.as.botClient); } - - const connManager = this.connectionManager = new ConnectionManager(this.as, - this.config, this.tokenStore, this.commentProcessor, this.messageClient, this.storage, this.github); + const connManager = this.connectionManager = new ConnectionManager( + this.as, + this.config, + this.tokenStore, + this.commentProcessor, + this.messageClient, + this.storage, + this.botUsersManager, + this.github, + ); if (this.config.feeds?.enabled) { new FeedReader( this.config.feeds, this.connectionManager, this.queue, + // Use default bot when storing account data this.as.botClient, ); } @@ -672,16 +680,20 @@ export class Bridge { await Promise.all(this.joinedRoomsManager.joinedRooms.map(async (roomId) => { log.debug("Fetching state for " + roomId); - const botUserId = this.joinedRoomsManager.getBotsInRoom(roomId)[0]; - const intent = this.as.getIntentForUserId(botUserId); - try { - await connManager.createConnectionsForRoomId(intent, roomId, false); + await connManager.createConnectionsForRoomId(roomId, false); } catch (ex) { log.error(`Unable to create connection for ${roomId}`, ex); return; } + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); + return; + } + const intent = this.as.getIntentForUserId(botUser.userId); + // TODO: Refactor this to be a connection try { let accountData = await intent.underlyingClient.getSafeRoomAccountData( @@ -816,11 +828,15 @@ export class Bridge { this.joinedRoomsManager.removeJoinedRoom(roomId, userId); - // If the bot has left the room, we want to vape all connections for that room. - try { - await this.connectionManager?.removeConnectionsForRoom(roomId); - } catch (ex) { - log.warn(`Failed to remove connections on leave for ${roomId}`); + if (!this.connectionManager) { + return; + } + + // Remove all the connections for this room + await this.connectionManager.removeConnectionsForRoom(roomId); + if (this.joinedRoomsManager.getBotsInRoom(roomId).length > 0) { + // If there are still bots in the room, recreate connections + await this.connectionManager.createConnectionsForRoomId(roomId, true); } } @@ -1059,7 +1075,7 @@ export class Bridge { } // We still want to react to our own state events. - if (event.sender === this.as.botUserId) { + if (this.botUsersManager.isBotUser(event.sender)) { // It's us return; } diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index bdf7b183a..d86a64e43 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -4,7 +4,7 @@ * Manages connections between Matrix rooms and the remote side. */ -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { CommentProcessor } from "./CommentProcessor"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; @@ -18,6 +18,7 @@ import { ApiError, ErrCode } from "./api"; import { UserTokenStore } from "./UserTokenStore"; import { FigmaFileConnection, FeedConnection } from "./Connections"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; +import BotUsersManager from "./Managers/BotUsersManager"; import Metrics from "./Metrics"; import EventEmitter from "events"; @@ -38,6 +39,7 @@ export class ConnectionManager extends EventEmitter { private readonly commentProcessor: CommentProcessor, private readonly messageClient: MessageSenderClient, private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, private readonly github?: GithubInstance ) { super(); @@ -62,13 +64,21 @@ export class ConnectionManager extends EventEmitter { /** * Used by the provisioner API to create new connections on behalf of users. + * + * @param intent Bot user intent to create the connection with. * @param roomId The target Matrix room. * @param userId The requesting Matrix user. * @param type The type of room (corresponds to the event type of the connection) * @param data The data corresponding to the connection state. This will be validated. * @returns The resulting connection. */ - public async provisionConnection(roomId: string, userId: string, type: string, data: Record) { + public async provisionConnection( + intent: Intent, + roomId: string, + userId: string, + type: string, + data: Record, + ) { log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`); const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type)); if (connectionType?.provisionConnection) { @@ -77,6 +87,7 @@ export class ConnectionManager extends EventEmitter { } const result = await connectionType.provisionConnection(roomId, userId, data, { as: this.as, + intent: intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -159,8 +170,17 @@ export class ConnectionManager extends EventEmitter { if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) { return; } + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connections for state`); + throw Error('Could not find a bot to handle this connection'); + } + const intent = this.as.getIntentForUserId(botUser.userId); + return connectionType.createConnectionForState(roomId, state, { as: this.as, + intent: intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -171,21 +191,26 @@ export class ConnectionManager extends EventEmitter { } public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) { - let connectionCreated = false; - const state = await this.as.botClient.getRoomState(roomId); + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when creating connections`); + return; + } + const intent = this.as.getIntentForUserId(botUser.userId); + + const state = await intent.underlyingClient.getRoomState(roomId); for (const event of state) { + // Choose a specific bot to user for the connection type try { const conn = await this.createConnectionForState(roomId, new StateEvent(event), rollbackBadState); if (conn) { log.debug(`Room ${roomId} is connected to: ${conn}`); this.push(conn); - connectionCreated = true; } } catch (ex) { log.error(`Failed to create connection for ${roomId}:`, ex); } } - return connectionCreated; } public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] { @@ -278,7 +303,7 @@ export class ConnectionManager extends EventEmitter { public getConnectionsForFeedUrl(url: string): FeedConnection[] { return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[]; } - + // eslint-disable-next-line @typescript-eslint/no-explicit-any public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] { return this.connections.filter((c) => (c instanceof typeT)) as T[]; @@ -333,7 +358,7 @@ export class ConnectionManager extends EventEmitter { /** * Removes connections for a room from memory. This does NOT remove the state * event from the room. - * @param roomId + * @param roomId */ public async removeConnectionsForRoom(roomId: string) { log.info(`Removing all connections from ${roomId}`); @@ -352,8 +377,8 @@ export class ConnectionManager extends EventEmitter { /** * Get a list of possible targets for a given connection type when provisioning - * @param userId - * @param type + * @param userId + * @param type */ async getConnectionTargets(userId: string, type: string, filters: Record = {}): Promise { switch (type) { From 50fb80154899e07160d9439c8f4f7436fde82959 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 17 Nov 2022 15:58:49 -0500 Subject: [PATCH 08/52] Use service bots in widget and provisioning APIs --- src/Bridge.ts | 11 ++++-- src/Widgets/BridgeWidgetApi.ts | 64 ++++++++++++++++++++++++++------- src/provisioning/provisioner.ts | 33 +++++++++++++---- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 0a9ab8bd1..f29b6ee4e 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -186,7 +186,13 @@ export class Bridge { if (this.config.generic) { this.connectionManager.registerProvisioningConnection(GenericHookConnection); } - this.provisioningApi = new Provisioner(this.config.provisioning, this.connectionManager, this.as.botIntent, routers); + this.provisioningApi = new Provisioner( + this.config.provisioning, + this.connectionManager, + this.botUsersManager, + this.as, + routers, + ); } this.as.on("query.room", async (roomAlias, cb) => { @@ -755,7 +761,8 @@ export class Bridge { this.storage, apps[0], this.connectionManager, - this.as.botIntent, + this.botUsersManager, + this.as, ); } diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 5f7ef991e..bcef6d144 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -7,8 +7,9 @@ import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { ConnectionManager } from "../ConnectionManager"; +import BotUsersManager from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; -import { Intent, PowerLevelsEvent } from "matrix-bot-sdk"; +import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; const log = new Logger("BridgeWidgetApi"); @@ -20,7 +21,8 @@ export class BridgeWidgetApi { storageProvider: IBridgeStorageProvider, expressApp: Application, private readonly connMan: ConnectionManager, - private readonly intent: Intent, + private readonly botUsersManager: BotUsersManager, + private readonly as: Appservice, ) { this.api = new ProvisioningApi( storageProvider, @@ -92,9 +94,18 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", this.intent); - const allConnections = this.connMan.getAllConnectionsForRoom(req.params.roomId as string); - const powerlevel = new PowerLevelsEvent({content: await this.intent.underlyingClient.getRoomStateEvent(req.params.roomId, "m.room.power_levels", "")}); + const roomId = req.params.roomId; + const serviceType = req.params.service; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + const intent = this.as.getIntentForUserId(botUser.userId); + + await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", intent); + const allConnections = this.connMan.getAllConnectionsForRoom(roomId); + const powerlevel = new PowerLevelsEvent({content: await intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); const serviceFilter = req.params.service; const connections = allConnections.map(c => c.getProvisionerDetails?.(true)) .filter(c => !!c) @@ -128,13 +139,22 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent); + const roomId = req.params.roomId; + const serviceType = req.params.type; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + const intent = this.as.getIntentForUserId(botUser.userId); + + await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", intent); try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(req.params.roomId, req.userId, req.params.type, req.body); + const result = await this.connMan.provisionConnection(intent, roomId, req.userId, serviceType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } @@ -152,15 +172,25 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent); - const connection = this.connMan.getConnectionById(req.params.roomId as string, req.params.connectionId as string); + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + const intent = this.as.getIntentForUserId(botUser.userId); + + await assertUserPermissionsInRoom(req.userId, roomId, "write", intent); + const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); } if (!connection.provisionerUpdateConfig || !connection.getProvisionerDetails) { throw new ApiError("Connection type does not support updates", ErrCode.UnsupportedOperation); } - this.connMan.validateCommandPrefix(req.params.roomId, req.body, connection); + this.connMan.validateCommandPrefix(roomId, req.body, connection); await connection.provisionerUpdateConfig(req.userId, req.body); res.send(connection.getProvisionerDetails(true)); } @@ -169,9 +199,17 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - const roomId = req.params.roomId as string; - const connectionId = req.params.connectionId as string; - await assertUserPermissionsInRoom(req.userId, roomId, "write", this.intent); + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + const intent = this.as.getIntentForUserId(botUser.userId); + + await assertUserPermissionsInRoom(req.userId, roomId, "write", intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 005ac944b..101382316 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -4,8 +4,9 @@ import { ConnectionManager } from "../ConnectionManager"; import { Logger } from "matrix-appservice-bridge"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem, GetConnectionTypeResponseItem } from "./api"; import { ApiError, ErrCode } from "../api"; -import { Intent } from "matrix-bot-sdk"; +import { Appservice } from "matrix-bot-sdk"; import Metrics from "../Metrics"; +import BotUsersManager from "../Managers/BotUsersManager"; const log = new Logger("Provisioner"); @@ -19,7 +20,8 @@ export class Provisioner { constructor( private readonly config: BridgeConfigProvisioning, private readonly connMan: ConnectionManager, - private readonly intent: Intent, + private readonly botUsersManager: BotUsersManager, + private readonly as: Appservice, additionalRoutes: {route: string, router: Router}[]) { if (!this.config.secret) { throw Error('Missing secret in provisioning config'); @@ -96,8 +98,15 @@ export class Provisioner { private async checkUserPermission(requiredPermission: "read"|"write", req: Request<{roomId: string}, unknown, unknown, {userId: string}>, res: Response, next: NextFunction) { const userId = req.query.userId; const roomId = req.params.roomId; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + + const intent = this.as.getIntentForUserId(botUser.userId); try { - await assertUserPermissionsInRoom(userId, roomId, requiredPermission, this.intent); + await assertUserPermissionsInRoom(userId, roomId, requiredPermission, intent); next(); } catch (ex) { next(ex); @@ -130,22 +139,34 @@ export class Provisioner { } private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record, {userId: string}>, res: Response, next: NextFunction) { + const roomId = req.params.roomId; + const serviceType = req.params.type; + const userId = req.query.userId; // Need to figure out which connections are available try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } - this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body); + this.connMan.validateCommandPrefix(roomId, req.body); + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + + const intent = this.as.getIntentForUserId(botUser.userId); + + const result = await this.connMan.provisionConnection(intent, roomId, userId, serviceType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } + res.send({ ...result.connection.getProvisionerDetails(true), warning: result.warning, }); } catch (ex) { - log.error(`Failed to create connection for ${req.params.roomId}`, ex); + log.error(`Failed to create connection for ${roomId}`, ex); return next(ex); } } From 569c4a466438da1ced69bacc9ca730467f81b67c Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 17 Nov 2022 16:01:33 -0500 Subject: [PATCH 09/52] Use service bots in setup connections --- src/Bridge.ts | 4 ++ src/Connections/CommandConnection.ts | 6 +- src/Connections/GithubRepo.ts | 1 + src/Connections/GitlabRepo.ts | 1 + src/Connections/JiraProject.ts | 1 + src/Connections/SetupConnection.ts | 104 ++++++++++++++------------- 6 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index f29b6ee4e..05e4a7d9a 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -897,6 +897,10 @@ export class Bridge { const setupConnection = new SetupConnection( roomId, botUser.prefix, + [ + ...botUser.services, + this.config.widgets?.roomSetupWidget ? "widget" : "", + ], { config: this.config, as: this.as, diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 7b511a76b..ac12393db 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -11,7 +11,6 @@ const log = new Logger("CommandConnection"); * by connections expecting to handle user input. */ export abstract class CommandConnection extends BaseConnection { - protected enabledHelpCategories?: string[]; protected includeTitlesInHelp?: boolean; constructor( roomId: string, @@ -21,6 +20,7 @@ export abstract class CommandConnection Promise, - private readonly pushConnections: (...connections: IConnection[]) => void) { - super( - roomId, - "", - "", - // TODO Consider storing room-specific config in state. - {}, - provisionOpts.as.botClient, - SetupConnection.botCommands, - SetupConnection.helpMessage, - "!hookshot", - ) - this.enabledHelpCategories = [ - this.config.github ? "github" : "", - this.config.gitlab ? "gitlab": "", - this.config.figma ? "figma": "", - this.config.jira ? "jira": "", - this.config.generic?.enabled ? "webhook": "", - this.config.feeds?.enabled ? "feed" : "", - this.config.widgets?.roomSetupWidget ? "widget" : "", - ]; - this.includeTitlesInHelp = false; + private readonly pushConnections: (...connections: IConnection[]) => void, + ) { + super( + roomId, + "", + "", + // TODO Consider storing room-specific config in state. + {}, + provisionOpts.intent.underlyingClient, + SetupConnection.botCommands, + SetupConnection.helpMessage, + helpCategories, + prefix, + ); + this.includeTitlesInHelp = false; } @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"}) @@ -84,7 +88,7 @@ export class SetupConnection extends CommandConnection { const [, org, repo] = urlParts; const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts); this.pushConnections(connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); + await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); } @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"}) @@ -110,7 +114,7 @@ export class SetupConnection extends CommandConnection { } const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts); this.pushConnections(connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : "")); + await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : "")); } private async checkJiraLogin(userId: string, urlStr: string) { @@ -142,12 +146,12 @@ export class SetupConnection extends CommandConnection { const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); this.pushConnections(res.connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); + await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) public async onJiraListProject() { - const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return []; // not an error to us } @@ -162,9 +166,9 @@ export class SetupConnection extends CommandConnection { ); if (projects.length === 0) { - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); + return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); } else { - return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + return this.client.sendHtmlNotice(this.roomId, md.render( 'Currently connected to these JIRA projects:\n\n' + projects.map(project => ` - ${project.url}`).join('\n') )); @@ -185,7 +189,7 @@ export class SetupConnection extends CommandConnection { let eventType = ""; for (eventType of eventTypes) { try { - event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl); + event = await this.client.getRoomStateEvent(this.roomId, eventType, safeUrl); break; } catch (err: any) { if (err.body.errcode !== 'M_NOT_FOUND') { @@ -197,11 +201,11 @@ export class SetupConnection extends CommandConnection { throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`); } - await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {}); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); + await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); } - @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhook"}) + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhooks"}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); @@ -217,7 +221,7 @@ export class SetupConnection extends CommandConnection { const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix); const adminRoom = await this.getOrCreateAdminRoom(userId); await adminRoom.sendNotice(`You have bridged a webhook. Please configure your webhook source to use ${url}.`); - return this.as.botClient.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); + return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); } @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"}) @@ -235,10 +239,10 @@ export class SetupConnection extends CommandConnection { const [, fileId] = res; const {connection} = await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts); this.pushConnections(connection); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); } - @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feed"}) + @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"}) public async onFeed(userId: string, url: string, label?: string) { if (!this.config.feeds?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support feeds."); @@ -260,12 +264,12 @@ export class SetupConnection extends CommandConnection { const {connection} = await FeedConnection.provisionConnection(this.roomId, userId, { url, label }, this.provisionOpts); this.pushConnections(connection); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); } - @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feed"}) + @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feeds"}) public async onFeedList() { - const feeds: FeedConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + const feeds: FeedConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return []; // not an error to us } @@ -277,7 +281,7 @@ export class SetupConnection extends CommandConnection { ); if (feeds.length === 0) { - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds')); + return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds')); } else { const feedDescriptions = feeds.map(feed => { if (feed.label) { @@ -286,18 +290,18 @@ export class SetupConnection extends CommandConnection { return feed.url; }); - return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + return this.client.sendHtmlNotice(this.roomId, md.render( 'Currently subscribed to these feeds:\n\n' + feedDescriptions.map(desc => ` - ${desc}`).join('\n') )); } } - @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feed"}) + @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"}) public async onFeedRemove(userId: string, url: string) { await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); - const event = await this.as.botClient.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { + const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return null; // not an error to us } @@ -307,8 +311,8 @@ export class SetupConnection extends CommandConnection { throw new CommandError("Invalid feed URL", `Feed "${url}" is not currently bridged to this room`); } - await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {}); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); + await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); } @botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"}) @@ -316,8 +320,8 @@ export class SetupConnection extends CommandConnection { if (!this.config.widgets?.roomSetupWidget) { throw new CommandError("Not configured", "The bridge is not configured to support setup widgets"); } - if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.as.botIntent, this.config.widgets)) { - await this.as.botClient.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); + if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets)) { + await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); } } @@ -325,10 +329,10 @@ export class SetupConnection extends CommandConnection { if (!this.config.checkPermission(userId, service, BridgePermissionLevel.manageConnections)) { throw new CommandError(`You are not permitted to provision connections for ${service}.`); } - if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) { + if (!await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) { throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to set up new integrations."); } - if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, stateEventType, true)) { + if (!await this.client.userHasPowerLevelFor(this.intent.userId, this.roomId, stateEventType, true)) { throw new CommandError("Bot lacks power level to set room state", "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator."); } } From 9b906c7ee307317e5ecd2fa6e1055d441d564241 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 17 Nov 2022 16:03:14 -0500 Subject: [PATCH 10/52] Use service bots for feed connections --- src/Connections/FeedConnection.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Connections/FeedConnection.ts b/src/Connections/FeedConnection.ts index 469b19779..336e0948e 100644 --- a/src/Connections/FeedConnection.ts +++ b/src/Connections/FeedConnection.ts @@ -1,4 +1,4 @@ -import {Appservice, StateEvent} from "matrix-bot-sdk"; +import {Appservice, Intent, StateEvent} from "matrix-bot-sdk"; import { IConnection, IConnectionState, InstantiateConnectionOpts } from "."; import { ApiError, ErrCode } from "../api"; import { BridgeConfigFeeds } from "../Config/Config"; @@ -10,7 +10,7 @@ import axios from "axios"; import markdown from "markdown-it"; import { Connection, ProvisionConnectionOpts } from "./IConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; -import { StatusCodes } from "http-status-codes"; +import { StatusCodes } from "http-status-codes"; const log = new Logger("FeedConnection"); const md = new markdown(); @@ -42,13 +42,13 @@ const MAX_LAST_RESULT_ITEMS = 5; export class FeedConnection extends BaseConnection implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed"; static readonly EventTypes = [ FeedConnection.CanonicalEventType ]; - static readonly ServiceCategory = "feed"; + static readonly ServiceCategory = "feeds"; - public static createConnectionForState(roomId: string, event: StateEvent, {config, as, storage}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) { if (!config.feeds?.enabled) { throw Error('RSS/Atom feeds are not configured'); } - return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, storage); + return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, intent, storage); } static async validateUrl(url: string): Promise { @@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection { } } - static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) { if (!config.feeds?.enabled) { throw new ApiError('RSS/Atom feeds are not configured', ErrCode.DisabledFeature); } @@ -90,8 +90,8 @@ export class FeedConnection extends BaseConnection implements IConnection { const state = { url, label: data.label }; - const connection = new FeedConnection(roomId, url, state, config.feeds, as, storage); - await as.botClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state); + const connection = new FeedConnection(roomId, url, state, config.feeds, as, intent, storage); + await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state); return { connection, @@ -104,14 +104,13 @@ export class FeedConnection extends BaseConnection implements IConnection { service: "feeds", eventType: FeedConnection.CanonicalEventType, type: "Feed", - // TODO: Add ability to configure the bot per connnection type. botUserId: botUserId, } } public getProvisionerDetails(): FeedResponseItem { return { - ...FeedConnection.getProvisionerDetails(this.as.botUserId), + ...FeedConnection.getProvisionerDetails(this.intent.userId), id: this.connectionId, config: { url: this.feedUrl, @@ -136,6 +135,7 @@ export class FeedConnection extends BaseConnection implements IConnection { private state: FeedConnectionState, private readonly config: BridgeConfigFeeds, private readonly as: Appservice, + private readonly intent: Intent, private readonly storage: IBridgeStorageProvider ) { super(roomId, stateKey, FeedConnection.CanonicalEventType) @@ -160,7 +160,7 @@ export class FeedConnection extends BaseConnection implements IConnection { message += `: ${entryDetails}`; } - await this.as.botIntent.sendEvent(this.roomId, { + await this.intent.sendEvent(this.roomId, { msgtype: 'm.notice', format: "org.matrix.custom.html", formatted_body: md.renderInline(message), @@ -190,7 +190,7 @@ export class FeedConnection extends BaseConnection implements IConnection { return; } if (!this.hasError) { - await this.as.botIntent.sendEvent(this.roomId, { + await this.intent.sendEvent(this.roomId, { msgtype: 'm.notice', format: 'm.text', body: `Error fetching ${this.feedUrl}: ${error.cause.message}` @@ -202,7 +202,7 @@ export class FeedConnection extends BaseConnection implements IConnection { // needed to ensure that the connection is removable public async onRemove(): Promise { log.info(`Removing connection ${this.connectionId}`); - await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {}); + await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {}); } toString(): string { From 306bb480460951a1b001dae72cc36a38f728d797 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Fri, 18 Nov 2022 15:32:06 -0500 Subject: [PATCH 11/52] Handle admin rooms for service bots --- src/Bridge.ts | 36 +++++++++++++++++++----------- src/Connections/SetupConnection.ts | 5 +++-- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 05e4a7d9a..0fa6085bd 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,6 +1,6 @@ import { AdminAccountData } from "./AdminRoomCommandHandler"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; -import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, IAppserviceRegistration } from "matrix-bot-sdk"; +import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, IAppserviceRegistration, Intent } from "matrix-bot-sdk"; import BotUsersManager from "./Managers/BotUsersManager"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; @@ -733,8 +733,7 @@ export class Bridge { // No state yet } } - // TODO Pass bot intent to set up admin room - const adminRoom = await this.setUpAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent()); + const adminRoom = await this.setUpAdminRoom(intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); // Call this on startup to set the state await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); @@ -813,7 +812,12 @@ export class Bridge { // Don't accept invites from people who can't do anything if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return intent.underlyingClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot."); + return intent.underlyingClient.kickUser(invitedUserId, roomId, "You do not have permission to invite this bot."); + } + + if (event.content.is_direct && invitedUserId !== this.as.botUserId) { + // Service bots do not support direct messages (admin rooms) + return intent.underlyingClient.kickUser(invitedUserId, roomId, "This bot does not support admin rooms."); } // Accept the invite @@ -1249,13 +1253,13 @@ export class Bridge { } - private async getOrCreateAdminRoomForUser(userId: string): Promise { + private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise { const existingRoom = this.getAdminRoomForUser(userId); if (existingRoom) { return existingRoom; } - const roomId = await this.as.botClient.dms.getOrCreateDm(userId); - const room = await this.setUpAdminRoom(roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); + const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId); + const room = await this.setUpAdminRoom(intent, roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); await this.as.botClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); @@ -1271,13 +1275,18 @@ export class Bridge { return null; } - private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) { + private async setUpAdminRoom( + intent: Intent, + roomId: string, + accountData: AdminAccountData, + notifContent: NotificationFilterStateContent, + ) { if (!this.connectionManager) { throw Error('setUpAdminRoom() called before connectionManager was ready'); } const adminRoom = new AdminRoom( - roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config, this.connectionManager, + roomId, accountData, notifContent, intent, this.tokenStore, this.config, this.connectionManager, ); adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); @@ -1287,7 +1296,7 @@ export class Bridge { const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); this.connectionManager?.push(connection); } else { - await this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); + await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); } }); adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => { @@ -1296,7 +1305,7 @@ export class Bridge { } const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || []; if (connection) { - return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); + return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); } const newConnection = await GitLabIssueConnection.createRoomForIssue( instanceName, @@ -1304,17 +1313,18 @@ export class Bridge { res, issueInfo.projects, this.as, + intent, this.tokenStore, this.commentProcessor, this.messageClient, this.config.gitlab, ); this.connectionManager?.push(newConnection); - return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId); + return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId); }); this.adminRooms.set(roomId, adminRoom); if (this.config.widgets?.addToAdminRooms) { - await SetupWidget.SetupAdminRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); + await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets); } log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`); return adminRoom; diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 7870eb65f..41a0354a6 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -14,6 +14,7 @@ import { AdminRoom } from "../AdminRoom"; import { GitLabRepoConnection } from "./GitlabRepo"; import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection"; import { ApiError, Logger } from "matrix-appservice-bridge"; +import { Intent } from "matrix-bot-sdk"; const md = new markdown(); const log = new Logger("SetupConnection"); @@ -52,7 +53,7 @@ export class SetupConnection extends CommandConnection { readonly prefix: string, readonly helpCategories: string[], private readonly provisionOpts: ProvisionConnectionOpts, - private readonly getOrCreateAdminRoom: (userId: string) => Promise, + private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise, private readonly pushConnections: (...connections: IConnection[]) => void, ) { super( @@ -219,7 +220,7 @@ export class SetupConnection extends CommandConnection { const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts); this.pushConnections(c.connection); const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix); - const adminRoom = await this.getOrCreateAdminRoom(userId); + const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); await adminRoom.sendNotice(`You have bridged a webhook. Please configure your webhook source to use ${url}.`); return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); } From 907a158ca724a0fb804aed01d84aa5fe4ce46070 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Mon, 28 Nov 2022 15:24:56 -0500 Subject: [PATCH 12/52] Fix confused event type and service type in provisioning and widget APIs --- src/ConnectionManager.ts | 13 ++++++++----- src/Widgets/BridgeWidgetApi.ts | 9 +++++++-- src/provisioning/provisioner.ts | 10 ++++++++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index d86a64e43..7e458e0fb 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -68,7 +68,7 @@ export class ConnectionManager extends EventEmitter { * @param intent Bot user intent to create the connection with. * @param roomId The target Matrix room. * @param userId The requesting Matrix user. - * @param type The type of room (corresponds to the event type of the connection) + * @param connectionType The connection declaration to provision. * @param data The data corresponding to the connection state. This will be validated. * @returns The resulting connection. */ @@ -76,11 +76,10 @@ export class ConnectionManager extends EventEmitter { intent: Intent, roomId: string, userId: string, - type: string, + connectionType: ConnectionDeclaration, data: Record, ) { - log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`); - const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type)); + log.info(`Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`); if (connectionType?.provisionConnection) { if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) { throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser); @@ -163,7 +162,7 @@ export class ConnectionManager extends EventEmitter { log.debug(`${roomId} has disabled state for ${state.type}`); return; } - const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type)); + const connectionType = this.getConnectionTypeForEventType(state.type); if (!connectionType) { return; } @@ -309,6 +308,10 @@ export class ConnectionManager extends EventEmitter { return this.connections.filter((c) => (c instanceof typeT)) as T[]; } + public getConnectionTypeForEventType(eventType: string): ConnectionDeclaration | undefined { + return ConnectionDeclarations.find(c => c.EventTypes.includes(eventType)); + } + public isRoomConnected(roomId: string): boolean { return !!this.connections.find(c => c.roomId === roomId); } diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index bcef6d144..6d44177a8 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -140,7 +140,12 @@ export class BridgeWidgetApi { throw Error('Cannot get connections without a valid userId'); } const roomId = req.params.roomId; - const serviceType = req.params.type; + const eventType = req.params.type; + const connectionType = this.connMan.getConnectionTypeForEventType(eventType); + if (!connectionType) { + throw new ApiError("Unknown event type", ErrCode.NotFound); + } + const serviceType = connectionType.ServiceCategory; const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); if (!botUser) { @@ -154,7 +159,7 @@ export class BridgeWidgetApi { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(intent, roomId, req.userId, serviceType, req.body); + const result = await this.connMan.provisionConnection(intent, roomId, req.userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 101382316..08ff01868 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -140,8 +140,14 @@ export class Provisioner { private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record, {userId: string}>, res: Response, next: NextFunction) { const roomId = req.params.roomId; - const serviceType = req.params.type; const userId = req.query.userId; + const eventType = req.params.type; + const connectionType = this.connMan.getConnectionTypeForEventType(eventType); + if (!connectionType) { + throw new ApiError("Unknown event type", ErrCode.NotFound); + } + const serviceType = connectionType.ServiceCategory; + // Need to figure out which connections are available try { if (!req.body || typeof req.body !== "object") { @@ -156,7 +162,7 @@ export class Provisioner { const intent = this.as.getIntentForUserId(botUser.userId); - const result = await this.connMan.provisionConnection(intent, roomId, userId, serviceType, req.body); + const result = await this.connMan.provisionConnection(intent, roomId, userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } From fd2ff9df542050a041ef301fe526d11638ac9229 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 13:48:31 -0500 Subject: [PATCH 13/52] Fix generic webhooks service name --- src/Config/Config.ts | 2 +- src/Connections/GenericHook.ts | 2 +- src/Connections/SetupConnection.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Config/Config.ts b/src/Config/Config.ts index a3026c87e..65d5454f2 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -668,7 +668,7 @@ export class BridgeConfig { services.push("figma"); } if (this.generic) { - services.push("webhooks"); + services.push("generic"); } if (this.github) { services.push("github"); diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 211dcf876..c0151a6b8 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -191,7 +191,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook"; - static readonly ServiceCategory = "webhooks"; + static readonly ServiceCategory = "generic"; static readonly EventTypes = [ GenericHookConnection.CanonicalEventType, diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 41a0354a6..0af959518 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -206,7 +206,7 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); } - @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhooks"}) + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); From a5fe181b169a6740c40bd4e88e9cd533fb705dd1 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 13:48:54 -0500 Subject: [PATCH 14/52] Fix enabled services config --- src/Config/Config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 65d5454f2..a28e295a8 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -661,22 +661,22 @@ export class BridgeConfig { public getEnabledServices(): string[] { const services = []; - if (this.feeds) { + if (this.feeds && this.feeds.enabled) { services.push("feeds"); } if (this.figma) { services.push("figma"); } - if (this.generic) { + if (this.generic && this.generic.enabled) { services.push("generic"); } if (this.github) { services.push("github"); } - if (this.github) { + if (this.gitlab) { services.push("gitlab"); } - if (this.github) { + if (this.jira) { services.push("jira"); } return services; From d71332e9647481c32e46c74e091cca7da1d95a61 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 13:51:03 -0500 Subject: [PATCH 15/52] Handle power level change --- src/Bridge.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 0fa6085bd..bcccab451 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1069,20 +1069,24 @@ export class Bridge { } } - // If it's a power level event for a new room, we might want to create the setup widget. - if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) { - log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`) - const plEvent = new PowerLevelsEvent(event); - const currentPl = plEvent.content.users?.[this.as.botUserId] || plEvent.defaultUserLevel; - const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || plEvent.previousContent?.users_default; - const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel; - if (currentPl !== previousPl && currentPl >= requiredPl) { - // PL changed for bot user, check to see if the widget can be created. - try { - log.info(`Bot has powerlevel required to create a setup widget, attempting`); - await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); - } catch (ex) { - log.error(`Failed to create setup widget for ${roomId}`, ex); + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + for (const botUser of botUsersInRoom) { + // If it's a power level event for a new room, we might want to create the setup widget. + if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) { + log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`) + const plEvent = new PowerLevelsEvent(event); + const currentPl = plEvent.content.users?.[botUser.userId] || plEvent.defaultUserLevel; + const previousPl = plEvent.previousContent?.users?.[botUser.userId] || plEvent.previousContent?.users_default; + const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel; + if (currentPl !== previousPl && currentPl >= requiredPl) { + // PL changed for bot user, check to see if the widget can be created. + try { + log.info(`Bot has powerlevel required to create a setup widget, attempting`); + const intent = this.as.getIntentForUserId(botUser.userId); + await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets); + } catch (ex) { + log.error(`Failed to create setup widget for ${roomId}`, ex); + } } } } From e5c22f2fb49271367c3a74d68fa590dac26f270e Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 13:53:30 -0500 Subject: [PATCH 16/52] Create widgets with service scope --- src/Bridge.ts | 37 ++++++++++++++++++------------ src/Connections/SetupConnection.ts | 3 ++- src/Widgets/SetupWidget.ts | 19 +++++++++++---- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index bcccab451..e72765acb 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -802,22 +802,23 @@ export class Bridge { } log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`); - if (!this.botUsersManager.isBotUser(invitedUserId)) { + const botUser = this.botUsersManager.getBotUser(invitedUserId); + if (!botUser) { // We got an invite but it's not a configured bot user, must be for a ghost user const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; return client.kickUser(invitedUserId, roomId, "Bridge does not support DMing ghosts"); } - const intent = this.as.getIntentForUserId(invitedUserId); + const intent = this.as.getIntentForUserId(botUser.userId); // Don't accept invites from people who can't do anything if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return intent.underlyingClient.kickUser(invitedUserId, roomId, "You do not have permission to invite this bot."); + return intent.underlyingClient.kickUser(botUser.userId, roomId, "You do not have permission to invite this bot."); } - if (event.content.is_direct && invitedUserId !== this.as.botUserId) { + if (event.content.is_direct && botUser.userId !== this.as.botUserId) { // Service bots do not support direct messages (admin rooms) - return intent.underlyingClient.kickUser(invitedUserId, roomId, "This bot does not support admin rooms."); + return intent.underlyingClient.kickUser(botUser.userId, roomId, "This bot does not support admin rooms."); } // Accept the invite @@ -901,6 +902,7 @@ export class Bridge { const setupConnection = new SetupConnection( roomId, botUser.prefix, + botUser.services, [ ...botUser.services, this.config.widgets?.roomSetupWidget ? "widget" : "", @@ -969,14 +971,19 @@ export class Bridge { private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) { const userId = matrixEvent.state_key; - if (!userId || !this.botUsersManager.isBotUser(userId)) { + if (!userId) { + return; + } + + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { // Not for one of our bots return; } - this.joinedRoomsManager.addJoinedRoom(roomId, userId); + this.joinedRoomsManager.addJoinedRoom(roomId, botUser.userId); - const intent = this.as.getIntentForUserId(userId); + const intent = this.as.getIntentForUserId(botUser.userId); if (this.config.encryption) { // Ensure crypto is aware of all members of this room before posting any messages, @@ -988,7 +995,7 @@ export class Bridge { BRIDGE_ROOM_TYPE, roomId, ); if (adminAccountData) { - const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent()); + const room = await this.setUpAdminRoom(intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); await intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); @@ -1010,17 +1017,17 @@ export class Bridge { // Otherwise it's a new room if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { try { - const hasPowerLevel = await intent.underlyingClient.userHasPowerLevelFor( + const hasPowerlevel = await intent.underlyingClient.userHasPowerLevelFor( intent.userId, roomId, "im.vector.modular.widgets", true, ); - if (!hasPowerLevel) { - await intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin"); + if (!hasPowerlevel) { + await intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); } else { - // Setup the widget - await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets); + // Set up the widget + await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets, botUser.services); } } catch (ex) { log.error(`Failed to setup new widget for room`, ex); @@ -1083,7 +1090,7 @@ export class Bridge { try { log.info(`Bot has powerlevel required to create a setup widget, attempting`); const intent = this.as.getIntentForUserId(botUser.userId); - await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets); + await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets, botUser.services); } catch (ex) { log.error(`Failed to create setup widget for ${roomId}`, ex); } diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 0af959518..3a6538841 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -51,6 +51,7 @@ export class SetupConnection extends CommandConnection { constructor( readonly roomId: string, readonly prefix: string, + readonly serviceTypes: string[], readonly helpCategories: string[], private readonly provisionOpts: ProvisionConnectionOpts, private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise, @@ -321,7 +322,7 @@ export class SetupConnection extends CommandConnection { if (!this.config.widgets?.roomSetupWidget) { throw new CommandError("Not configured", "The bridge is not configured to support setup widgets"); } - if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets)) { + if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets, this.serviceTypes)) { await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); } } diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index ad97c02d4..65792b2d4 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -16,15 +16,24 @@ export class SetupWidget { return false; } - static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise { - if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config")) { + static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise { + // If this is for a single service, scope the widget + const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined; + if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config", serviceScope)) { await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`); return true; } return false; } - private static async createWidgetInRoom(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, kind: HookshotWidgetKind, stateKey: string): Promise { + private static async createWidgetInRoom( + roomId: string, + botIntent: Intent, + config: BridgeWidgetConfig, + kind: HookshotWidgetKind, + stateKey: string, + serviceScope?: string, + ): Promise { log.info(`Running SetupRoomConfigWidget for ${roomId}`); if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) { throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator."); @@ -58,10 +67,10 @@ export class SetupWidget { "id": stateKey, "name": config.branding.widgetTitle, "type": "m.custom", - "url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id`, config.parsedPublicUrl).href, + "url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ''}`, config.parsedPublicUrl).href, "waitForIframeLoad": true, } ); return true; } -} \ No newline at end of file +} From 41d10b6911da44bdcb57d5338e41dc461255d287 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 15:03:44 -0500 Subject: [PATCH 17/52] Use service bots for gitlab repo connections --- src/Connections/GitlabRepo.ts | 98 ++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 9ae5bdbcb..3d53fd472 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1,7 +1,7 @@ // We need to instantiate some functions which are not directly called, which confuses typescript. /* eslint-disable @typescript-eslint/ban-ts-comment */ import { UserTokenStore } from "../UserTokenStore"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; @@ -164,7 +164,7 @@ export class GitLabRepoConnection extends CommandConnection>, {as, tokenStore, config}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, event: StateEvent>, {intent, tokenStore, config}: InstantiateConnectionOpts) { if (!config.gitlab) { throw Error('GitLab is not configured'); } @@ -173,10 +173,10 @@ export class GitLabRepoConnection extends CommandConnection, { config, as, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) { + public static async provisionConnection(roomId: string, requester: string, data: Record, { config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) { if (!config.gitlab) { throw Error('GitLab is not configured'); } @@ -204,7 +204,7 @@ export class GitLabRepoConnection extends CommandConnection c.roomId === roomId && c.path === connection.path); @@ -244,7 +244,7 @@ export class GitLabRepoConnection extends CommandConnection({ maxSize: 100 }); private readonly hookFilter: HookFilter; - constructor(roomId: string, + constructor( + roomId: string, stateKey: string, - private readonly as: Appservice, + private readonly intent: Intent, state: GitLabRepoConnectionState, private readonly tokenStore: UserTokenStore, - private readonly instance: GitLabInstance) { - super( - roomId, - stateKey, - GitLabRepoConnection.CanonicalEventType, - state, - as.botClient, - GitLabRepoConnection.botCommands, - GitLabRepoConnection.helpMessage, - ["gitlab"], - "!gl", - "gitlab", - ) - if (!state.path || !state.instance) { - throw Error('Invalid state, missing `path` or `instance`'); - } - this.hookFilter = new HookFilter( - // GitLab allows all events by default - AllowedEvents, - [], - state.ignoreHooks, - ); + private readonly instance: GitLabInstance, + ) { + super( + roomId, + stateKey, + GitLabRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitLabRepoConnection.botCommands, + GitLabRepoConnection.helpMessage, + ["gitlab"], + "!gl", + "gitlab", + ) + if (!state.path || !state.instance) { + throw Error('Invalid state, missing `path` or `instance`'); + } + this.hookFilter = new HookFilter( + // GitLab allows all events by default + AllowedEvents, + [], + state.ignoreHooks, + ); } public get path() { @@ -367,7 +369,7 @@ export class GitLabRepoConnection extends CommandConnection "); } - this.as.botIntent.sendEvent(this.roomId, { + this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content, formatted_body: md.renderInline(content), @@ -779,7 +781,7 @@ ${data.description}`; // Apply previous state to the current config, as provisioners might not return "unknown" keys. config = { ...this.state, ...config }; const validatedConfig = GitLabRepoConnection.validateState(config); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); this.state = validatedConfig; this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? []; } @@ -788,11 +790,11 @@ ${data.description}`; log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } // TODO: Clean up webhooks } From c600f08eb0f63eb8af57f494e5c0992e8480a32b Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 29 Nov 2022 15:04:53 -0500 Subject: [PATCH 18/52] Use service bots for gitlab issue connections --- src/Connections/GitlabIssue.ts | 66 ++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index f86234931..6c70d561f 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { UserTokenStore } from "../UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; @@ -48,8 +48,11 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` } - public static async createConnectionForState(roomId: string, event: StateEvent, { - config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts, + ) { if (!config.gitlab) { throw Error('GitHub is not configured'); } @@ -58,15 +61,31 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection throw Error('Instance name not recognised'); } return new GitLabIssueConnection( - roomId, as, event.content, event.stateKey || "", tokenStore, - commentProcessor, messageClient, instance, config.gitlab, + roomId, + as, + intent, + event.content, + event.stateKey || "", + tokenStore, + commentProcessor, + messageClient, + instance, + config.gitlab, ); } - public static async createRoomForIssue(instanceName: string, instance: GitLabInstance, - issue: GetIssueResponse, projects: string[], as: Appservice, - tokenStore: UserTokenStore, commentProcessor: CommentProcessor, - messageSender: MessageSenderClient, config: BridgeConfigGitLab) { + public static async createRoomForIssue( + instanceName: string, + instance: GitLabInstance, + issue: GetIssueResponse, + projects: string[], + as: Appservice, + intent: Intent, + tokenStore: UserTokenStore, + commentProcessor: CommentProcessor, + messageSender: MessageSenderClient, + config: BridgeConfigGitLab, + ) { const state: GitLabIssueConnectionState = { projects, state: issue.state, @@ -76,7 +95,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection authorName: issue.author.name, }; - const roomId = await as.botClient.createRoom({ + const roomId = await intent.underlyingClient.createRoom({ visibility: "private", name: `${issue.references.full}`, topic: GitLabIssueConnection.getTopicString(issue.author.name, issue.state), @@ -91,7 +110,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection ], }); - return new GitLabIssueConnection(roomId, as, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config); + return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config); } public get projectPath() { @@ -102,18 +121,21 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection return this.instance.url; } - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private state: GitLabIssueConnectionState, stateKey: string, private tokenStore: UserTokenStore, private commentProcessor: CommentProcessor, private messageClient: MessageSenderClient, private instance: GitLabInstance, - private config: BridgeConfigGitLab) { - super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); - } - + private config: BridgeConfigGitLab, + ) { + super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); + } + public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitLabIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } @@ -147,7 +169,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl); if (clientKit === null) { - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: event.event_id, @@ -178,8 +200,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public async onIssueReopened() { // TODO: We don't store the author data. this.state.state = "reopened"; - await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state); - return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state); + return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), }); } @@ -187,8 +209,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public async onIssueClosed() { // TODO: We don't store the author data. this.state.state = "closed"; - await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state); - return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state); + return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), }); } @@ -206,4 +228,4 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public toString() { return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`; } -} \ No newline at end of file +} From f3fb25678dd19903acfd2aa9737cc4162742f4f6 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 30 Nov 2022 13:45:19 -0500 Subject: [PATCH 19/52] Use service bots for generic webhook connections --- src/Connections/GenericHook.ts | 98 +++++++++++++++++----------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index c0151a6b8..335b7d50b 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -4,7 +4,7 @@ import { MessageSenderClient } from "../MatrixSender" import markdownit from "markdown-it"; import { VMScript as Script, NodeVM } from "vm2"; import { MatrixEvent } from "../MatrixEvent"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { v4 as uuid} from "uuid"; import { ApiError, ErrCode } from "../api"; import { BaseConnection } from "./BaseConnection"; @@ -68,14 +68,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * Ensures a JSON payload is compatible with Matrix JSON requirements, such * as disallowing floating point values. - * + * * If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned. * If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked. - * + * * @param data The data to santise * @param depth The depth of the `data` relative to the root. * @param breadth The breadth of the `data` in the parent object. - * @returns + * @returns */ static sanitiseObjectForMatrixJSON(data: unknown, depth = 0, breadth = 0): unknown { // Floats @@ -91,7 +91,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) { return JSON.stringify(data); } - + const newDepth = depth + 1; if (Array.isArray(data)) { return data.map((d, innerBreadth) => this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth)); @@ -130,19 +130,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection }; } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, config, messageClient}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient}: InstantiateConnectionOpts) { if (!config.generic) { throw Error('Generic webhooks are not configured'); } // Generic hooks store the hookId in the account data - const acctData = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); + const acctData = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); const state = this.validateState(event.content); // hookId => stateKey let hookId = Object.entries(acctData).find(([, v]) => v === event.stateKey)?.[0]; if (!hookId) { hookId = uuid(); log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`); - await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, event.stateKey); + await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey); } return new GenericHookConnection( @@ -153,18 +153,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection messageClient, config.generic, as, + intent, ); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, messageClient}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, messageClient}: ProvisionConnectionOpts) { if (!config.generic) { throw Error('Generic Webhooks are not configured'); } const hookId = uuid(); const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false); - await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, validState.name); - await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); - const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as); + await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name); + await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); + const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent); return { connection, stateEventContent: validState, @@ -173,19 +174,16 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * This function ensures the account data for a room contains all the hookIds for the various state events. - * @param roomId - * @param as - * @param connection */ - static async ensureRoomAccountData(roomId: string, as: Appservice, hookId: string, stateKey: string, remove = false) { - const data = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); + static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) { + const data = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); if (remove && data[hookId] === stateKey) { delete data[hookId]; - await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); + await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); } if (!remove && data[hookId] !== stateKey) { data[hookId] = stateKey; - await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); + await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); } } @@ -200,23 +198,25 @@ export class GenericHookConnection extends BaseConnection implements IConnection private transformationFunction?: Script; private cachedDisplayname?: string; - constructor(roomId: string, + constructor( + roomId: string, private state: GenericHookConnectionState, public readonly hookId: string, stateKey: string, private readonly messageClient: MessageSenderClient, private readonly config: BridgeConfigGenericWebhooks, - private readonly as: Appservice) { - super(roomId, stateKey, GenericHookConnection.CanonicalEventType); - if (state.transformationFunction && config.allowJsTransformationFunctions) { - this.transformationFunction = new Script(state.transformationFunction); - } - } - - public get priority(): number { - return this.state.priority || super.priority; + private readonly as: Appservice, + private readonly intent: Intent, + ) { + super(roomId, stateKey, GenericHookConnection.CanonicalEventType); + if (state.transformationFunction && config.allowJsTransformationFunctions) { + this.transformationFunction = new Script(state.transformationFunction); } + } + public get priority(): number { + return this.state.priority || super.priority; + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -224,28 +224,24 @@ export class GenericHookConnection extends BaseConnection implements IConnection public getUserId() { if (!this.config.userIdPrefix) { - return this.as.botUserId; + return this.intent.userId; } - const [, domain] = this.as.botUserId.split(':'); + const [, domain] = this.intent.userId.split(':'); const name = this.state.name && this.state.name.replace(/[A-Z]/g, (s) => s.toLowerCase()).replace(/([^a-z0-9\-.=_]+)/g, ''); return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`; } - public async ensureDisplayname(sender: string) { + public async ensureDisplayname(userId: string) { if (!this.state.name) { return; } - if (sender === this.as.botUserId) { - // Don't set the global displayname for the bot. - return; - } - const intent = this.as.getIntentForUserId(sender); + const intent = this.as.getIntentForUserId(userId); const expectedDisplayname = `${this.state.name} (Webhook)`; try { if (this.cachedDisplayname !== expectedDisplayname) { - this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(sender)).displayname; + this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(userId)).displayname; } } catch (ex) { // Couldn't fetch, probably not set. @@ -264,7 +260,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection try { this.transformationFunction = new Script(validatedConfig.transformationFunction); } catch (ex) { - await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex); + await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId); } } else { this.transformationFunction = undefined; @@ -352,7 +348,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * Processes an incoming generic hook * @param data Structured data. This may either be a string, or an object. - * @returns `true` if the webhook completed, or `false` if it failed to complete + * @returns `true` if the webhook completed, or `false` if it failed to complete */ public async onGenericHook(data: unknown): Promise { log.info(`onGenericHook ${this.roomId} ${this.hookId}`); @@ -376,11 +372,15 @@ export class GenericHookConnection extends BaseConnection implements IConnection } const sender = this.getUserId(); - await this.ensureDisplayname(sender); + if (sender !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(sender, this.roomId); + await this.ensureDisplayname(sender); + } // Matrix cannot handle float data, so make sure we parse out any floats. const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); - + await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: content.msgtype || "m.notice", body: content.plain, @@ -405,7 +405,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection public getProvisionerDetails(showSecrets = false): GenericHookResponseItem { return { - ...GenericHookConnection.getProvisionerDetails(this.as.botUserId), + ...GenericHookConnection.getProvisionerDetails(this.intent.userId), id: this.connectionId, config: { transformationFunction: this.state.transformationFunction, @@ -422,20 +422,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } - await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, this.hookId, this.stateKey, true); + await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true); } public async provisionerUpdateConfig(userId: string, config: Record) { // Apply previous state to the current config, as provisioners might not return "unknown" keys. config = { ...this.state, ...config }; const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { ...validatedConfig, hookId: this.hookId From 36b4d3050ecb96a4ac27277eda249b6f24e68d76 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 13:56:06 -0500 Subject: [PATCH 20/52] Use service bots for figma file connections --- src/Connections/FigmaFileConnection.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts index c42f4bfde..bae5f847f 100644 --- a/src/Connections/FigmaFileConnection.ts +++ b/src/Connections/FigmaFileConnection.ts @@ -1,4 +1,4 @@ -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import markdownit from "markdown-it"; import { FigmaPayload } from "../figma/types"; import { BaseConnection } from "./BaseConnection"; @@ -29,7 +29,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { ]; static readonly ServiceCategory = "figma"; - + public static validateState(data: Record): FigmaFileConnectionState { if (!data.fileId || typeof data.fileId !== "string") { throw Error('Missing or invalid fileId'); @@ -43,20 +43,20 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { } } - public static createConnectionForState(roomId: string, event: StateEvent, {config, as, storage}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) { if (!config.figma) { throw Error('Figma is not configured'); } - return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, storage); + return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, intent, storage); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) { if (!config.figma) { throw Error('Figma is not configured'); } const validState = this.validateState(data); - const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, storage); - await as.botClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState); + const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, intent, storage); + await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState); return { connection, stateEventContent: validState, @@ -70,6 +70,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { private state: FigmaFileConnectionState, private readonly config: BridgeConfigFigma, private readonly as: Appservice, + private readonly intent: Intent, private readonly storage: IBridgeStorageProvider) { super(roomId, stateKey, FigmaFileConnection.CanonicalEventType) } @@ -100,8 +101,13 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { return; } - const intent = this.as.getIntentForUserId(this.config.overrideUserId || this.as.botUserId); - + let intent; + if (this.config.overrideUserId) { + intent = this.as.getIntentForUserId(this.config.overrideUserId); + } else { + intent = this.intent; + } + const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`; const comment = payload.comment.map(({text}) => text).join("\n"); const empty = "‎"; // This contains an empty character to thwart the notification matcher. @@ -109,7 +115,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { let content: Record|undefined = undefined; const parentEventId = payload.parent_id && await this.storage.getFigmaCommentEventId(this.roomId, payload.parent_id); if (parentEventId) { - content = { + content = { "m.relates_to": { rel_type: THREAD_RELATION_TYPE, event_id: parentEventId, From ef27a8e2ec70b73e9fd3266392878fe025821d3c Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 14:13:37 -0500 Subject: [PATCH 21/52] Use service bots when verifying state events --- src/ConnectionManager.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 7e458e0fb..e96fa90ec 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -105,14 +105,15 @@ export class ConnectionManager extends EventEmitter { * Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers. * If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible. * @param roomId The target Matrix room. + * @param intent The bot intent to use. * @param state The state event for altering a connection in the room. * @param serviceType The type of connection the state event is altering. * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. */ - public verifyStateEvent(roomId: string, state: StateEvent, serviceType: string, rollbackBadState: boolean) { + public verifyStateEvent(roomId: string, intent: Intent, state: StateEvent, serviceType: string, rollbackBadState: boolean) { if (!this.isStateAllowed(roomId, state, serviceType)) { if (rollbackBadState) { - void this.tryRestoreState(roomId, state, serviceType); + void this.tryRestoreState(roomId, intent, state, serviceType); } log.error(`User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); return false; @@ -127,30 +128,36 @@ export class ConnectionManager extends EventEmitter { * @param state The state event for altering a connection in the room targeted by {@link connection}. * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. */ - public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean) { + public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean): boolean { const cd: ConnectionDeclaration = Object.getPrototypeOf(connection).constructor; - return !this.verifyStateEvent(connection.roomId, state, cd.ServiceCategory, rollbackBadState); + const botUser = this.botUsersManager.getBotUserInRoom(connection.roomId, cd.ServiceCategory); + if (!botUser) { + log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`); + throw Error('Could not find a bot to handle this connection'); + } + const intent = this.as.getIntentForUserId(botUser.userId); + return !this.verifyStateEvent(connection.roomId, intent, state, cd.ServiceCategory, rollbackBadState); } private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) { - return state.sender === this.as.botUserId + return this.botUsersManager.isBotUser(state.sender) || this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections); } - private async tryRestoreState(roomId: string, originalState: StateEvent, serviceType: string) { + private async tryRestoreState(roomId: string, intent: Intent, originalState: StateEvent, serviceType: string) { let state = originalState; let attemptsRemaining = 5; try { do { if (state.unsigned.replaces_state) { - state = new StateEvent(await this.as.botClient.getEvent(roomId, state.unsigned.replaces_state)); + state = new StateEvent(await intent.underlyingClient.getEvent(roomId, state.unsigned.replaces_state)); } else { - await this.as.botClient.redactEvent(roomId, originalState.eventId, + await intent.underlyingClient.redactEvent(roomId, originalState.eventId, `User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); return; } } while (--attemptsRemaining > 0 && !this.isStateAllowed(roomId, state, serviceType)); - await this.as.botClient.sendStateEvent(roomId, state.type, state.stateKey, state.content); + await intent.underlyingClient.sendStateEvent(roomId, state.type, state.stateKey, state.content); } catch (ex) { log.warn(`Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`); } @@ -166,17 +173,18 @@ export class ConnectionManager extends EventEmitter { if (!connectionType) { return; } - if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) { - return; - } const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory); if (!botUser) { - log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connections for state`); + log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`); throw Error('Could not find a bot to handle this connection'); } const intent = this.as.getIntentForUserId(botUser.userId); + if (!this.verifyStateEvent(roomId, intent, state, connectionType.ServiceCategory, rollbackBadState)) { + return; + } + return connectionType.createConnectionForState(roomId, state, { as: this.as, intent: intent, From cbfba7334ee17efb88a2ca71c2c11f480dee8113 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 16:48:52 -0500 Subject: [PATCH 22/52] Use service bots for github repo connections --- src/Connections/GithubRepo.ts | 106 +++++++++++++++++----------------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 5105dabbf..673b59bbb 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommentProcessor } from "../CommentProcessor"; import { FormatUtil } from "../FormatUtil"; @@ -81,12 +81,12 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep export type GitHubRepoResponseItem = GetConnectionsResponseItem; -type AllowedEventsNames = +type AllowedEventsNames = "issue.changed" | "issue.created" | "issue.edited" | "issue.labeled" | - "issue" | + "issue" | "pull_request.closed" | "pull_request.merged" | "pull_request.opened" | @@ -97,7 +97,7 @@ type AllowedEventsNames = "release.drafted" | "release" | "workflow" | - "workflow.run" | + "workflow.run" | "workflow.run.success" | "workflow.run.failure" | "workflow.run.neutral" | @@ -171,7 +171,7 @@ const ConnectionStateSchema = { nullable: true, maxLength: 24, }, - showIssueRoomLink: { + showIssueRoomLink: { type: "boolean", nullable: true, }, @@ -249,7 +249,7 @@ const ConnectionStateSchema = { additionalProperties: true } as JSONSchemaType; -type ReactionOptions = +type ReactionOptions = | "+1" | "-1" | "laugh" @@ -327,7 +327,7 @@ export class GitHubRepoConnection extends CommandConnection, {as, tokenStore, github, config}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } @@ -361,10 +361,10 @@ export class GitHubRepoConnection extends CommandConnection>, {as, tokenStore, github, config}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, state: StateEvent>, {as, intent, tokenStore, github, config}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } - return new GitHubRepoConnection(roomId, as, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github); + return new GitHubRepoConnection(roomId, as, intent, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github); } static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise { @@ -469,26 +469,28 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>(); - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, state: GitHubRepoConnectionState, private readonly tokenStore: UserTokenStore, stateKey: string, private readonly githubInstance: GithubInstance, private readonly config: BridgeConfigGitHub, - ) { - super( - roomId, - stateKey, - GitHubRepoConnection.CanonicalEventType, - state, - as.botClient, - GitHubRepoConnection.botCommands, - GitHubRepoConnection.helpMessage, - ["github"], - "!gh", - "github", - ); + ) { + super( + roomId, + stateKey, + GitHubRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitHubRepoConnection.botCommands, + GitHubRepoConnection.helpMessage, + ["github"], + "!gh", + "github", + ); this.hookFilter = new HookFilter( AllowHookByDefault, state.enableHooks, @@ -582,7 +584,7 @@ export class GitHubRepoConnection extends CommandConnection w.name.toLowerCase().trim() === name.toLowerCase().trim()); if (!workflow) { const workflowNames = workflows.data.workflows.map(w => w.name).join(', '); - await this.as.botIntent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice"); + await this.intent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice"); return; } try { @@ -781,7 +783,7 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); - await this.as.botIntent.sendEvent(this.roomId, { + const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + await this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), @@ -855,7 +857,7 @@ export class GitHubRepoConnection extends CommandConnection { const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] }; @@ -903,9 +905,9 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); const content = `**${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`; - this.as.botIntent.sendEvent(this.roomId, { + this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (plain.length > 0 ? ` with labels ${plain}`: ""), formatted_body: md.renderInline(content) + (html.length > 0 ? ` with labels ${html}`: ""), @@ -962,8 +964,8 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); - await this.as.botIntent.sendEvent(this.roomId, { + const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + await this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: "") + diffContent, formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: "") + diffContentHtml, @@ -986,7 +988,7 @@ export class GitHubRepoConnection extends CommandConnection Date: Thu, 1 Dec 2022 16:59:31 -0500 Subject: [PATCH 23/52] Use service bots for github discussion connections --- src/Bridge.ts | 7 +++++ src/Connections/GithubDiscussion.ts | 41 +++++++++++++++++------------ src/Managers/BotUsersManager.ts | 19 ++++++++++--- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index e72765acb..103cb1e20 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -505,10 +505,17 @@ export class Bridge { } let [discussionConnection] = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.id); if (!discussionConnection) { + const botUser = this.botUsersManager.getBotUserForService(GitHubDiscussionConnection.ServiceCategory); + if (!botUser) { + throw Error('Could not find a bot to handle this connection'); + } + const intent = this.as.getIntentForUserId(botUser.userId); + try { // If we don't have an existing connection for this discussion (likely), then create one. discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom( this.as, + intent, null, data.repository.owner.login, data.repository.name, diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts index 14bbc28d7..8ef9c115d 100644 --- a/src/Connections/GithubDiscussion.ts +++ b/src/Connections/GithubDiscussion.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { UserTokenStore } from "../UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; @@ -42,12 +42,12 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne static readonly ServiceCategory = "github"; public static createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } return new GitHubDiscussionConnection( - roomId, as, event.content, event.stateKey, tokenStore, commentProcessor, + roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor, messageClient, config.github, ); } @@ -55,7 +55,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne readonly sentEvents = new Set(); //TODO: Set some reasonable limits public static async createDiscussionRoom( - as: Appservice, userId: string|null, owner: string, repo: string, discussion: Discussion, + as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion, tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient, config: BridgeConfigGitHub, ) { @@ -71,7 +71,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne discussion: discussion.number, category: discussion.category.id, }; - const invite = [as.botUserId]; + const invite = [intent.userId]; if (userId) { invite.push(userId); } @@ -93,20 +93,23 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne formatted_body: md.render(discussion.body), format: 'org.matrix.custom.html', }); - await as.botIntent.ensureJoined(roomId); - return new GitHubDiscussionConnection(roomId, as, state, '', tokenStore, commentProcessor, messageClient, config); + await intent.ensureJoined(roomId); + return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config); } - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private readonly state: GitHubDiscussionConnectionState, stateKey: string, private readonly tokenStore: UserTokenStore, private readonly commentProcessor: CommentProcessor, private readonly messageClient: MessageSenderClient, - private readonly config: BridgeConfigGitHub) { - super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); - } + private readonly config: BridgeConfigGitHub, + ) { + super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubDiscussionConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -116,7 +119,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne const octokit = await this.tokenStore.getOctokitForUser(ev.sender); if (octokit === null) { // TODO: Use Reply - Also mention user. - await this.as.botClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`); + await this.intent.underlyingClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`); return true; } const qlClient = new GithubGraphQLClient(octokit); @@ -147,6 +150,10 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne return; } const intent = await getIntentForUser(data.comment.user, this.as, this.config.userIdPrefix); + if (intent.userId !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(intent.userId, this.roomId); + } await this.messageClient.sendMatrixMessage(this.roomId, { body: data.comment.body, formatted_body: md.render(data.comment.body), @@ -160,11 +167,11 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } -} \ No newline at end of file +} diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 14f699030..fe8b51c26 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -17,6 +17,9 @@ export interface BotUser { priority: number; } +// Sort bot users by highest priority first. +const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (a.priority < b.priority) ? 1 : -1; + export default class BotUsersManager { // Map of user ID to config for all our configured bot users private _botUsers = new Map(); @@ -58,12 +61,13 @@ export default class BotUsersManager { } /** - * Gets the configured bot users. + * Gets the configured bot users, ordered by priority. * * @returns List of bot users. */ get botUsers(): Readonly[] { - return Array.from(this._botUsers.values()); + return Array.from(this._botUsers.values()) + .sort(higherPriority) } /** @@ -94,7 +98,7 @@ export default class BotUsersManager { return this.joinedRoomsManager.getBotsInRoom(roomId) .map(botUserId => this.getBotUser(botUserId)) .filter((b): b is BotUser => b !== undefined) - .sort((a, b) => (a.priority < b.priority) ? 1 : -1) + .sort(higherPriority); } /** @@ -112,4 +116,13 @@ export default class BotUsersManager { return botUsersInRoom[0]; } } + + /** + * Gets the bot user with the highest priority for a particular service. + * + * @param serviceType Service type for the bot. + */ + getBotUserForService(serviceType: string): Readonly | undefined { + return this.botUsers.find(b => b.services.includes(serviceType)); + } } From 9c84e1ef7fcb9d15a756bb1a6fcd93dd2bc9ce10 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:01:29 -0500 Subject: [PATCH 24/52] Use service bots for github discussion space connections --- src/Connections/GithubDiscussionSpace.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Connections/GithubDiscussionSpace.ts b/src/Connections/GithubDiscussionSpace.ts index 30617ff83..fa158afc2 100644 --- a/src/Connections/GithubDiscussionSpace.ts +++ b/src/Connections/GithubDiscussionSpace.ts @@ -31,12 +31,12 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection static readonly ServiceCategory = "github"; public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as}: InstantiateConnectionOpts) { + github, config, as, intent}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } return new GitHubDiscussionSpace( - await as.botClient.getSpace(roomId), event.content, event.stateKey + await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey ); } @@ -108,7 +108,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection preset: 'public_chat', room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`, initial_state: [ - + { type: this.CanonicalEventType, content: state, @@ -172,7 +172,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - + await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey); await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { @@ -180,4 +180,4 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } -} \ No newline at end of file +} From 0ee691e8ba135fe625806742ddebf46d166f4e8d Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:02:18 -0500 Subject: [PATCH 25/52] Use service bots for github project connections --- src/Bridge.ts | 2 +- src/Connections/GithubProject.ts | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 103cb1e20..70ef74c04 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1311,7 +1311,7 @@ export class Bridge { adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { const [connection] = this.connectionManager?.getForGitHubProject(project.id) || []; if (!connection) { - const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); + const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, adminRoom.userId); this.connectionManager?.push(connection); } else { await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index 92bee4aff..4b4c21d5e 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { ProjectsGetResponseData } from "../Github/Types"; import { BaseConnection } from "./BaseConnection"; @@ -24,14 +24,14 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti GitHubProjectConnection.LegacyCanonicalEventType, ]; - public static createConnectionForState(roomId: string, event: StateEvent, {config, as}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent}: InstantiateConnectionOpts) { if (!config.github) { throw Error('GitHub is not configured'); } - return new GitHubProjectConnection(roomId, as, event.content, event.stateKey); + return new GitHubProjectConnection(roomId, as, intent, event.content, event.stateKey); } - static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise { + static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, inviteUser: string): Promise { log.info(`Fetching ${project.name} ${project.id}`); // URL hack so we don't need to fetch the repo itself. @@ -41,7 +41,7 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti state: project.state as "open"|"closed", }; - const roomId = await as.botClient.createRoom({ + const roomId = await intent.underlyingClient.createRoom({ visibility: "private", name: `${project.name}`, topic: project.body || undefined, @@ -55,20 +55,23 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti }, ], }); - - return new GitHubProjectConnection(roomId, as, state, project.url) + + return new GitHubProjectConnection(roomId, as, intent, state, project.url) } get projectId() { return this.state.project_id; } - constructor(public readonly roomId: string, + constructor( + public readonly roomId: string, as: Appservice, + intent: Intent, private state: GitHubProjectConnectionState, - stateKey: string) { - super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); - } + stateKey: string, + ) { + super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -77,4 +80,4 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti public toString() { return `GitHubProjectConnection ${this.state.project_id}}`; } -} \ No newline at end of file +} From ada3b0d48091de402afea4a422001f1106096ae8 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:04:00 -0500 Subject: [PATCH 26/52] Use service bots for github issue connections --- src/Connections/GithubIssue.ts | 49 ++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index 46cafe277..5ce90b823 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import markdown from "markdown-it"; import { UserTokenStore } from "../UserTokenStore"; @@ -56,12 +56,12 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection } public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } const issue = new GitHubIssueConnection( - roomId, as, event.content, event.stateKey || "", tokenStore, + roomId, as, intent, event.content, event.stateKey || "", tokenStore, commentProcessor, messageClient, github, config.github, ); await issue.syncIssueState(); @@ -154,17 +154,20 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }; } - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private state: GitHubIssueConnectionState, stateKey: string, private tokenStore: UserTokenStore, private commentProcessor: CommentProcessor, private messageClient: MessageSenderClient, private github: GithubInstance, - private config: BridgeConfigGitHub,) { - super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); - } + private config: BridgeConfigGitHub, + ) { + super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -214,13 +217,17 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue); // Comment body may be blank if (matrixEvent) { + if (commentIntent.userId !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(commentIntent.userId, this.roomId); + } await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); } if (!updateState) { return; } this.state.comments_processed++; - await this.as.botIntent.underlyingClient.sendStateEvent( + await this.intent.underlyingClient.sendStateEvent( this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, @@ -245,6 +252,10 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }, this.as, this.config.userIdPrefix); // We've not sent any messages into the room yet, let's do it! if (issue.data.body) { + if (creator.userId !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(creator.userId, this.roomId); + } await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: "m.text", external_url: issue.data.html_url, @@ -282,6 +293,10 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection if (issue.data.state === "closed") { // TODO: Fix const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string); + if (closedUserId !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(closedUserId, this.roomId); + } await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: "m.notice", body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`, @@ -289,14 +304,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }, "m.room.message", closedUserId); } - await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: FormatUtil.formatRoomTopic(issue.data), }); this.state.state = issue.data.state; } - await this.as.botIntent.underlyingClient.sendStateEvent( + await this.intent.underlyingClient.sendStateEvent( this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, @@ -308,7 +323,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { const clientKit = await this.tokenStore.getOctokitForUser(event.sender); if (clientKit === null) { - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: event.event_id, @@ -339,7 +354,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection // TODO: Fix types if (event.issue && event.changes.title) { - await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { name: FormatUtil.formatIssueRoomName(event.issue, event.repository), }); } @@ -349,11 +364,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } @@ -374,4 +389,4 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection public toString() { return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`; } -} \ No newline at end of file +} From bc0dbc729f183361386053dd964192533f5b3afd Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:04:31 -0500 Subject: [PATCH 27/52] Use service bots for github user space connections --- src/Connections/GithubUserSpace.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Connections/GithubUserSpace.ts b/src/Connections/GithubUserSpace.ts index ba3d230e7..ec867d879 100644 --- a/src/Connections/GithubUserSpace.ts +++ b/src/Connections/GithubUserSpace.ts @@ -29,12 +29,12 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { static readonly ServiceCategory = "github"; public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as}: InstantiateConnectionOpts) { + github, config, intent}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } return new GitHubUserSpace( - await as.botClient.getSpace(roomId), event.content, event.stateKey + await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey ); } @@ -104,7 +104,7 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { preset: 'public_chat', room_alias_name: `github_${state.username.toLowerCase()}`, initial_state: [ - + { type: this.CanonicalEventType, content: state, @@ -167,4 +167,4 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { await this.space.addChildRoom(discussion.roomId); } } -} \ No newline at end of file +} From 9b3e2b1f8fa8afcedde20682a486fb8743f111b3 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:07:12 -0500 Subject: [PATCH 28/52] Use service bots for jira connections --- src/Connections/JiraProject.ts | 91 +++++++++++++++++----------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 245bed97a..326b99165 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; @@ -106,7 +106,7 @@ export class JiraProjectConnection extends CommandConnection MatrixMessageContent; - static async provisionConnection(roomId: string, userId: string, data: Record, {getAllConnectionsOfType, as, tokenStore, config}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record, {getAllConnectionsOfType, as, intent, tokenStore, config}: ProvisionConnectionOpts) { if (!config.jira) { throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature); } @@ -120,7 +120,7 @@ export class JiraProjectConnection extends CommandConnection>, {config, as, tokenStore}: InstantiateConnectionOpts) { + + static createConnectionForState(roomId: string, state: StateEvent>, {config, as, intent, tokenStore}: InstantiateConnectionOpts) { if (!config.jira) { throw Error('JIRA is not configured'); } const connectionConfig = validateJiraConnectionState(state.content); - return new JiraProjectConnection(roomId, as, connectionConfig, state.stateKey, tokenStore); + return new JiraProjectConnection(roomId, as, intent, connectionConfig, state.stateKey, tokenStore); } - + public get projectId() { return this.state.id; } @@ -194,37 +194,39 @@ export class JiraProjectConnection extends CommandConnection t.name).join(', ')}`; - return this.as.botIntent.sendEvent(this.roomId,{ + return this.intent.sendEvent(this.roomId,{ msgtype: "m.notice", body: content, formatted_body: md.render(content), @@ -499,11 +500,11 @@ export class JiraProjectConnection extends CommandConnection Date: Thu, 1 Dec 2022 17:17:57 -0500 Subject: [PATCH 29/52] Make sure ghost users are invited for gitlab issue comments --- src/Connections/GitlabIssue.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 6c70d561f..6ce9bf86c 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -163,6 +163,10 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection }, this.as, this.config.userIdPrefix); const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event); + if (commentIntent.userId !== this.intent.userId) { + // Make sure ghost user is invited to the room + await this.intent.underlyingClient.inviteUser(commentIntent.userId, this.roomId); + } await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); } From 179ebc8ba87f276def6ebabf88f723c583074d8f Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:18:45 -0500 Subject: [PATCH 30/52] Configure one service per service bot --- config.sample.yml | 3 +-- src/Config/Config.ts | 2 +- src/Config/Defaults.ts | 2 +- src/Managers/BotUsersManager.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/config.sample.yml b/config.sample.yml index d4b35e09b..93f1c7aff 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -109,8 +109,7 @@ serviceBots: displayname: Feeds avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d prefix: "!feeds" - services: - - feeds + service: feeds metrics: # (Optional) Prometheus metrics support # diff --git a/src/Config/Config.ts b/src/Config/Config.ts index a28e295a8..a3dc22b35 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -416,7 +416,7 @@ export interface BridgeConfigServiceBot { displayname?: string; avatar?: string; prefix: string; - services: string[]; + service: string; } export interface BridgeConfigProvisioning { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index 4eea93aab..fdb850c33 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -53,7 +53,7 @@ export const DefaultConfig = new BridgeConfig({ displayname: "Feeds", avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d", prefix: "!feeds", - services: ["feeds"], + service: "feeds", }, ], github: { diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index fe8b51c26..b27c98bab 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -51,7 +51,7 @@ export default class BotUsersManager { userId: userId, avatar: bot.avatar, displayname: bot.displayname, - services: bot.services, + services: [bot.service], prefix: bot.prefix, // Service bots should handle commands first priority: 1, From 734b31c00264e2ee5e1b43e6b950e6a9efa8a18c Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 1 Dec 2022 17:21:31 -0500 Subject: [PATCH 31/52] Add changelog --- changelog.d/573.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/573.feature diff --git a/changelog.d/573.feature b/changelog.d/573.feature new file mode 100644 index 000000000..df8f68f63 --- /dev/null +++ b/changelog.d/573.feature @@ -0,0 +1 @@ +Implement "service bots", which are additional bot users configured to handle a particular service. From c7880b0a5148c5725810cafe157683e446c0e898 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Fri, 2 Dec 2022 13:46:28 -0500 Subject: [PATCH 32/52] Update tests --- tests/AdminRoomTest.ts | 4 ++-- tests/connections/GenericHookTest.ts | 6 ++++-- tests/connections/GithubRepoTest.ts | 26 ++++++++++++++------------ tests/connections/GitlabRepoTest.ts | 27 ++++++++++++++------------- tests/utils/AppserviceMock.ts | 8 ++++---- tests/utils/IntentMock.ts | 8 +++++--- 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index 236c5b2d0..56698c3d8 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -10,7 +10,7 @@ import { IntentMock } from "./utils/IntentMock"; const ROOM_ID = "!foo:bar"; function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, IntentMock] { - const intent = IntentMock.create(); + const intent = IntentMock.create("@admin:bar"); if (!data.admin_user) { data.admin_user = "@admin:bar"; } @@ -28,4 +28,4 @@ describe("AdminRoom", () => { content: AdminRoom.helpMessage(undefined, ["Github", "Gitlab", "Jira"]), }); }); -}) \ No newline at end of file +}) diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index 43f35722c..f9cceb35a 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -17,7 +17,9 @@ function createGenericHook(state: GenericHookConnectionState = { const mq = new LocalMQ(); mq.subscribe('*'); const messageClient = new MessageSenderClient(mq); - const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create()) + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@webhooks:example.test'); + const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), as, intent); return [connection, mq]; } @@ -30,7 +32,7 @@ function handleMessage(mq: LocalMQ): Promise { data: { 'eventId': '$foo:bar' }, }); r(msg.data as IMatrixSendMessage); - })); + })); } describe("GenericHookConnection", () => { diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index dd871fb80..a476233bb 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -39,10 +39,12 @@ function createConnection(state: Record = {}, isExistingState=f }); mq.subscribe('*'); const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@github:example.test'); const githubInstance = new GithubInstance("foo", "bar", new URL("https://github.com")); const connection = new GitHubRepoConnection( ROOM_ID, as, + intent, GitHubRepoConnection.validateState({ org: "a-fake-org", repo: "a-fake-repo", @@ -55,7 +57,7 @@ function createConnection(state: Record = {}, isExistingState=f // eslint-disable-next-line @typescript-eslint/no-non-null-assertion DefaultConfig.github! ); - return {connection, as}; + return {connection, intent}; } describe("GitHubRepoConnection", () => { @@ -116,15 +118,15 @@ describe("GitHubRepoConnection", () => { }); describe("onIssueCreated", () => { it("will handle a simple issue", async () => { - const { connection, as } = createConnection(); + const { connection, intent } = createConnection(); await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); // Statement text. - as.botIntent.expectEventBodyContains('**alice** created new issue', 0); - as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); - as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); + intent.expectEventBodyContains('**alice** created new issue', 0); + intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); + intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); it("will filter out issues not matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingLabels: ["include-me"] }); await connection.onIssueCreated({ @@ -138,10 +140,10 @@ describe("GitHubRepoConnection", () => { } as never); // ..or issues with no labels await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will filter out issues matching excludingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] }); await connection.onIssueCreated({ @@ -153,10 +155,10 @@ describe("GitHubRepoConnection", () => { }], } } as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will include issues matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingIssues: ["include-me"] }); await connection.onIssueCreated({ @@ -168,7 +170,7 @@ describe("GitHubRepoConnection", () => { }], } } as never); - as.botIntent.expectEventBodyContains('**alice** created new issue', 0); + intent.expectEventBodyContains('**alice** created new issue', 0); }); }); -}); \ No newline at end of file +}); diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 1d887d4ea..73b78403d 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -37,10 +37,11 @@ function createConnection(state: Record = {}, isExistingState=f }); mq.subscribe('*'); const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@gitlab:example.test'); const connection = new GitLabRepoConnection( ROOM_ID, "state_key", - as, + intent, GitLabRepoConnection.validateState({ instance: "bar", path: "foo", @@ -51,7 +52,7 @@ function createConnection(state: Record = {}, isExistingState=f url: "https://gitlab.example.com" }, ); - return {connection, as}; + return {connection, intent}; } describe("GitLabRepoConnection", () => { @@ -114,15 +115,15 @@ describe("GitLabRepoConnection", () => { }); describe("onIssueCreated", () => { it("will handle a simple issue", async () => { - const { connection, as } = createConnection(); + const { connection, intent } = createConnection(); await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); // Statement text. - as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0); - as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); - as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); + intent.expectEventBodyContains('**alice** opened a new MR', 0); + intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); + intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); }); it("will filter out issues not matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingLabels: ["include-me"] }); await connection.onMergeRequestOpened({ @@ -133,10 +134,10 @@ describe("GitLabRepoConnection", () => { } as never); // ..or issues with no labels await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will filter out issues matching excludingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] }); await connection.onMergeRequestOpened({ @@ -145,10 +146,10 @@ describe("GitLabRepoConnection", () => { title: "exclude-me", }], } as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will include issues matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingIssues: ["include-me"] }); await connection.onMergeRequestOpened({ @@ -157,7 +158,7 @@ describe("GitLabRepoConnection", () => { title: "include-me", }], } as never); - as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0); + intent.expectEventBodyContains('**alice** opened a new MR', 0); }); }); -}); \ No newline at end of file +}); diff --git a/tests/utils/AppserviceMock.ts b/tests/utils/AppserviceMock.ts index 1f43561f0..c654c080a 100644 --- a/tests/utils/AppserviceMock.ts +++ b/tests/utils/AppserviceMock.ts @@ -1,21 +1,21 @@ import { IntentMock } from "./IntentMock"; export class AppserviceMock { - public readonly botIntent = IntentMock.create(); + public readonly botIntent = IntentMock.create(`@bot:example.com`); static create(){ // eslint-disable-next-line @typescript-eslint/no-explicit-any return new this() as any; } get botUserId() { - return `@bot:example.com`; + return this.botIntent.userId; } get botClient() { return this.botIntent.underlyingClient; } - public getIntentForUserId() { - return IntentMock.create(); + public getIntentForUserId(userId: string) { + return IntentMock.create(userId); } } diff --git a/tests/utils/IntentMock.ts b/tests/utils/IntentMock.ts index e256fa576..10f666b34 100644 --- a/tests/utils/IntentMock.ts +++ b/tests/utils/IntentMock.ts @@ -10,9 +10,11 @@ export class IntentMock { public readonly underlyingClient = new MatrixClientMock(); public sentEvents: {roomId: string, content: any}[] = []; - static create(){ + constructor(readonly userId: string) {} + + static create(userId: string){ // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new this() as any; + return new this(userId) as any; } sendText(roomId: string, noticeText: string, msgtype: string) { @@ -31,7 +33,7 @@ export class IntentMock { content, }); } - + expectNoEvent() { expect(this.sentEvents, 'Expected no events to be sent.').to.be.empty; } From 87a185d2c776d0e41accf8502a2f178abc437f3b Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Fri, 9 Dec 2022 17:29:55 -0500 Subject: [PATCH 33/52] Fix up following rebase --- src/App/BridgeApp.ts | 2 +- src/Bridge.ts | 2 +- src/Connections/GithubRepo.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 703fcde1f..4dde28276 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -35,7 +35,7 @@ async function start() { userNotificationWatcher.start(); } - const bridgeApp = new Bridge(config, listener, appservice, storage); + const bridgeApp = new Bridge(config, listener, registration, appservice, storage); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/Bridge.ts b/src/Bridge.ts index 70ef74c04..32f054a4d 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -925,7 +925,7 @@ export class Bridge { github: this.github, getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager), }, - this.getOrCreateAdminRoomForUser.bind(this), + this.getOrCreateAdminRoom.bind(this), this.connectionManager.push.bind(this.connectionManager), ); const handled = await setupConnection.onMessageEvent(event, checkPermission); diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 673b59bbb..9ccf5177a 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -1097,7 +1097,7 @@ export class GitHubRepoConnection extends CommandConnection Date: Fri, 9 Dec 2022 17:36:34 -0500 Subject: [PATCH 34/52] Fix comment --- src/ConnectionManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index e96fa90ec..6839a72d1 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -174,6 +174,7 @@ export class ConnectionManager extends EventEmitter { return; } + // Get a bot user for the connection type const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory); if (!botUser) { log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`); @@ -207,7 +208,6 @@ export class ConnectionManager extends EventEmitter { const state = await intent.underlyingClient.getRoomState(roomId); for (const event of state) { - // Choose a specific bot to user for the connection type try { const conn = await this.createConnectionForState(roomId, new StateEvent(event), rollbackBadState); if (conn) { From 312110c9d8b1e69dc668972b2ef81efb7876e7b0 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 13:02:46 -0500 Subject: [PATCH 35/52] Use getter for enabled services --- src/Config/Config.ts | 2 +- src/Managers/BotUsersManager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config/Config.ts b/src/Config/Config.ts index a3dc22b35..e9978e2a3 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -659,7 +659,7 @@ export class BridgeConfig { return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]); } - public getEnabledServices(): string[] { + public get enabledServices(): string[] { const services = []; if (this.feeds && this.feeds.enabled) { services.push("feeds"); diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index b27c98bab..025e716da 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -37,7 +37,7 @@ export default class BotUsersManager { avatar: this.config.bot?.avatar, displayname: this.config.bot?.displayname, // Default bot can handle all services - services: this.config.getEnabledServices(), + services: this.config.enabledServices, prefix: "!hookshot", priority: 0, }); From 3588ea47f5737ee7e488c86951379074a1282185 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 13:38:20 -0500 Subject: [PATCH 36/52] Ensure homeserver can be reached before registering bots --- src/Bridge.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 32f054a4d..f2969efdc 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -90,6 +90,19 @@ export class Bridge { await this.storage.connect?.(); await this.queue.connect?.(); + log.info("Ensuring homeserver can be reached..."); + let reached = false; + while (!reached) { + try { + // Make a request to determine if we can reach the homeserver + await this.as.botIntent.getJoinedRooms(); + reached = true; + } catch (e) { + log.warn("Failed to connect to homeserver, retrying in 5s", e); + await new Promise((r) => setTimeout(r, 5000)); + } + } + log.info("Ensuring bot users are set up..."); // Register and set profiles for all our bots @@ -116,12 +129,12 @@ export class Bridge { } } - log.info("Connecting to homeserver and fetching joined rooms..."); + log.info("Fetching joined rooms..."); // Collect joined rooms for all our bots for (const botUser of this.botUsersManager.botUsers) { const intent = this.as.getIntentForUserId(botUser.userId); - const joinedRooms = await retry(() => intent.underlyingClient.getJoinedRooms(), 3, 3000); + const joinedRooms = await intent.underlyingClient.getJoinedRooms(); log.debug(`Bot "${botUser.userId}" is joined to ${joinedRooms.length} rooms`); for (const r of joinedRooms) { From fb20fa4d4b22dd1a12ae36089c91b1f007d2c0c0 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 16:33:23 -0500 Subject: [PATCH 37/52] Add intent getter on bot user --- src/Bridge.ts | 61 +++++++++++--------------- src/ConnectionManager.ts | 11 ++--- src/Managers/BotUsersManager.ts | 77 ++++++++++++++++++++------------- src/Widgets/BridgeWidgetApi.ts | 16 +++---- src/provisioning/provisioner.ts | 7 +-- 5 files changed, 84 insertions(+), 88 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index f2969efdc..f61c60a4d 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -108,24 +108,23 @@ export class Bridge { // Register and set profiles for all our bots for (const botUser of this.botUsersManager.botUsers) { // Ensure the bot is registered - const intent = this.as.getIntentForUserId(botUser.userId); log.debug(`Ensuring '${botUser.userId}' is registered`); - await intent.ensureRegistered(); + await botUser.intent.ensureRegistered(); // Set up the bot profile let profile; try { - profile = await intent.underlyingClient.getUserProfile(botUser.userId); + profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); } catch { profile = {} } if (botUser.avatar && profile.avatar_url !== botUser.avatar) { log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); - await intent.underlyingClient.setAvatarUrl(botUser.avatar); + await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); } if (botUser.displayname && profile.displayname !== botUser.displayname) { log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); - await intent.underlyingClient.setDisplayName(botUser.displayname); + await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); } } @@ -133,8 +132,7 @@ export class Bridge { // Collect joined rooms for all our bots for (const botUser of this.botUsersManager.botUsers) { - const intent = this.as.getIntentForUserId(botUser.userId); - const joinedRooms = await intent.underlyingClient.getJoinedRooms(); + const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); log.debug(`Bot "${botUser.userId}" is joined to ${joinedRooms.length} rooms`); for (const r of joinedRooms) { @@ -522,13 +520,12 @@ export class Bridge { if (!botUser) { throw Error('Could not find a bot to handle this connection'); } - const intent = this.as.getIntentForUserId(botUser.userId); try { // If we don't have an existing connection for this discussion (likely), then create one. discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom( this.as, - intent, + botUser.intent, null, data.repository.owner.login, data.repository.name, @@ -718,15 +715,14 @@ export class Bridge { log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); return; } - const intent = this.as.getIntentForUserId(botUser.userId); // TODO: Refactor this to be a connection try { - let accountData = await intent.underlyingClient.getSafeRoomAccountData( + let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { - accountData = await intent.underlyingClient.getSafeRoomAccountData( + accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( LEGACY_BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { @@ -734,18 +730,18 @@ export class Bridge { return; } else { // Upgrade the room - await intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); } } let notifContent; try { - notifContent = await intent.underlyingClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.StateType, "", ); } catch (ex) { try { - notifContent = await intent.underlyingClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.LegacyStateType, "", ); } @@ -753,7 +749,7 @@ export class Bridge { // No state yet } } - const adminRoom = await this.setUpAdminRoom(intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); + const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); // Call this on startup to set the state await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); @@ -829,22 +825,20 @@ export class Bridge { return client.kickUser(invitedUserId, roomId, "Bridge does not support DMing ghosts"); } - const intent = this.as.getIntentForUserId(botUser.userId); - // Don't accept invites from people who can't do anything if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return intent.underlyingClient.kickUser(botUser.userId, roomId, "You do not have permission to invite this bot."); + return botUser.intent.underlyingClient.kickUser(botUser.userId, roomId, "You do not have permission to invite this bot."); } if (event.content.is_direct && botUser.userId !== this.as.botUserId) { // Service bots do not support direct messages (admin rooms) - return intent.underlyingClient.kickUser(botUser.userId, roomId, "This bot does not support admin rooms."); + return botUser.intent.underlyingClient.kickUser(botUser.userId, roomId, "This bot does not support admin rooms."); } // Accept the invite - await retry(() => intent.joinRoom(roomId), 5); + await retry(() => botUser.intent.joinRoom(roomId), 5); if (event.content.is_direct) { - await intent.underlyingClient.setRoomAccountData( + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, ); } @@ -930,7 +924,7 @@ export class Bridge { { config: this.config, as: this.as, - intent: this.as.getIntentForUserId(botUser.userId), + intent: botUser.intent, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, messageClient: this.messageClient, @@ -1003,20 +997,18 @@ export class Bridge { this.joinedRoomsManager.addJoinedRoom(roomId, botUser.userId); - const intent = this.as.getIntentForUserId(botUser.userId); - if (this.config.encryption) { // Ensure crypto is aware of all members of this room before posting any messages, // so that the bot can share room keys to all recipients first. - await intent.underlyingClient.crypto.onRoomJoin(roomId); + await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId); } - const adminAccountData = await intent.underlyingClient.getSafeRoomAccountData( + const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (adminAccountData) { - const room = await this.setUpAdminRoom(intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); - await intent.underlyingClient.setRoomAccountData( + const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); } @@ -1037,17 +1029,17 @@ export class Bridge { // Otherwise it's a new room if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { try { - const hasPowerlevel = await intent.underlyingClient.userHasPowerLevelFor( - intent.userId, + const hasPowerlevel = await botUser.intent.underlyingClient.userHasPowerLevelFor( + botUser.intent.userId, roomId, "im.vector.modular.widgets", true, ); if (!hasPowerlevel) { - await intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); + await botUser.intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); } else { // Set up the widget - await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets, botUser.services); + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); } } catch (ex) { log.error(`Failed to setup new widget for room`, ex); @@ -1109,8 +1101,7 @@ export class Bridge { // PL changed for bot user, check to see if the widget can be created. try { log.info(`Bot has powerlevel required to create a setup widget, attempting`); - const intent = this.as.getIntentForUserId(botUser.userId); - await SetupWidget.SetupRoomConfigWidget(roomId, intent, this.config.widgets, botUser.services); + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); } catch (ex) { log.error(`Failed to create setup widget for ${roomId}`, ex); } diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 6839a72d1..8bf886ee3 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -135,8 +135,7 @@ export class ConnectionManager extends EventEmitter { log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`); throw Error('Could not find a bot to handle this connection'); } - const intent = this.as.getIntentForUserId(botUser.userId); - return !this.verifyStateEvent(connection.roomId, intent, state, cd.ServiceCategory, rollbackBadState); + return !this.verifyStateEvent(connection.roomId, botUser.intent, state, cd.ServiceCategory, rollbackBadState); } private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) { @@ -180,15 +179,14 @@ export class ConnectionManager extends EventEmitter { log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`); throw Error('Could not find a bot to handle this connection'); } - const intent = this.as.getIntentForUserId(botUser.userId); - if (!this.verifyStateEvent(roomId, intent, state, connectionType.ServiceCategory, rollbackBadState)) { + if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) { return; } return connectionType.createConnectionForState(roomId, state, { as: this.as, - intent: intent, + intent: botUser.intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -204,9 +202,8 @@ export class ConnectionManager extends EventEmitter { log.error(`Failed to find a bot in room '${roomId}' when creating connections`); return; } - const intent = this.as.getIntentForUserId(botUser.userId); - const state = await intent.underlyingClient.getRoomState(roomId); + const state = await botUser.intent.underlyingClient.getRoomState(roomId); for (const event of state) { try { const conn = await this.createConnectionForState(roomId, new StateEvent(event), rollbackBadState); diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 025e716da..90b053277 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -1,4 +1,4 @@ -import { Appservice, IAppserviceRegistration } from "matrix-bot-sdk"; +import { Appservice, IAppserviceRegistration, Intent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfig } from "../Config/Config"; @@ -6,15 +6,22 @@ import JoinedRoomsManager from "./JoinedRoomsManager"; const log = new Logger("BotUsersManager"); -export interface BotUser { - localpart: string; - userId: string; - avatar?: string; - displayname?: string; - services: string[]; - prefix: string; - // Bots with higher priority should handle a command first - priority: number; +export class BotUser { + constructor( + private readonly as: Appservice, + readonly localpart: string, + readonly userId: string, + readonly services: string[], + readonly prefix: string, + // Bots with higher priority should handle a command first + readonly priority: number, + readonly avatar?: string, + readonly displayname?: string, + ) {} + + get intent(): Intent { + return this.as.getIntentForUserId(this.userId); + } } // Sort bot users by highest priority first. @@ -31,31 +38,39 @@ export default class BotUsersManager { readonly joinedRoomsManager: JoinedRoomsManager, ) { // Default bot user - this._botUsers.set(this.as.botUserId, { - localpart: registration.sender_localpart, - userId: this.as.botUserId, - avatar: this.config.bot?.avatar, - displayname: this.config.bot?.displayname, - // Default bot can handle all services - services: this.config.enabledServices, - prefix: "!hookshot", - priority: 0, - }); + this._botUsers.set( + this.as.botUserId, + new BotUser( + this.as, + registration.sender_localpart, + this.as.botUserId, + // Default bot can handle all services + this.config.enabledServices, + "!hookshot", + 0, + this.config.bot?.avatar, + this.config.bot?.displayname, + ) + ); // Service bot users if (this.config.serviceBots) { this.config.serviceBots.forEach(bot => { - const userId = this.as.getUserId(bot.localpart); - this._botUsers.set(userId, { - localpart: bot.localpart, - userId: userId, - avatar: bot.avatar, - displayname: bot.displayname, - services: [bot.service], - prefix: bot.prefix, - // Service bots should handle commands first - priority: 1, - }); + const botUserId = this.as.getUserId(bot.localpart); + this._botUsers.set( + botUserId, + new BotUser( + this.as, + bot.localpart, + botUserId, + [bot.service], + bot.prefix, + // Service bots should handle commands first + 1, + bot.avatar, + bot.displayname, + ) + ); }); } } diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 6d44177a8..0846efcfe 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -101,11 +101,10 @@ export class BridgeWidgetApi { if (!botUser) { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", intent); + await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", botUser.intent); const allConnections = this.connMan.getAllConnectionsForRoom(roomId); - const powerlevel = new PowerLevelsEvent({content: await intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); + const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); const serviceFilter = req.params.service; const connections = allConnections.map(c => c.getProvisionerDetails?.(true)) .filter(c => !!c) @@ -151,15 +150,14 @@ export class BridgeWidgetApi { if (!botUser) { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", intent); + await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", botUser.intent); try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(intent, roomId, req.userId, connectionType, req.body); + const result = await this.connMan.provisionConnection(botUser.intent, roomId, req.userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } @@ -185,9 +183,8 @@ export class BridgeWidgetApi { if (!botUser) { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); - await assertUserPermissionsInRoom(req.userId, roomId, "write", intent); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); @@ -212,9 +209,8 @@ export class BridgeWidgetApi { if (!botUser) { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); - await assertUserPermissionsInRoom(req.userId, roomId, "write", intent); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 08ff01868..816c46f57 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -104,9 +104,8 @@ export class Provisioner { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); try { - await assertUserPermissionsInRoom(userId, roomId, requiredPermission, intent); + await assertUserPermissionsInRoom(userId, roomId, requiredPermission, botUser.intent); next(); } catch (ex) { next(ex); @@ -160,9 +159,7 @@ export class Provisioner { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const intent = this.as.getIntentForUserId(botUser.userId); - - const result = await this.connMan.provisionConnection(intent, roomId, userId, connectionType, req.body); + const result = await this.connMan.provisionConnection(botUser.intent, roomId, userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } From 99c5d0607d444ea9478fc2ae7a1a37f1e1b0273b Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 16:38:19 -0500 Subject: [PATCH 38/52] Update config comment --- config.sample.yml | 2 +- src/Config/Config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.sample.yml b/config.sample.yml index 93f1c7aff..7aac3624d 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -103,7 +103,7 @@ bot: displayname: Hookshot Bot avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d serviceBots: - # (Optional) EXPERIMENTAL Define additional bot users for specific services + # (Optional) Define additional bot users for specific services # - localpart: feeds displayname: Feeds diff --git a/src/Config/Config.ts b/src/Config/Config.ts index e9978e2a3..4e70cb0cd 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -485,7 +485,7 @@ export class BridgeConfig { public readonly feeds?: BridgeConfigFeeds; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; - @configKey("EXPERIMENTAL Define additional bot users for specific services", true) + @configKey("Define additional bot users for specific services", true) public readonly serviceBots?: BridgeConfigServiceBot[]; @configKey("EXPERIMENTAL support for complimentary widgets", true) public readonly widgets?: BridgeWidgetConfig; From 9b49c10c2c8b4e7a5a059ab5681c435fb2ed55fd Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 17:24:48 -0500 Subject: [PATCH 39/52] Merge joined rooms manager with bot users manager --- src/Bridge.ts | 28 ++++++------- src/Managers/BotUsersManager.ts | 58 ++++++++++++++++++++++----- src/Managers/JoinedRoomsManager.ts | 63 ------------------------------ 3 files changed, 62 insertions(+), 87 deletions(-) delete mode 100644 src/Managers/JoinedRoomsManager.ts diff --git a/src/Bridge.ts b/src/Bridge.ts index f61c60a4d..e2db1ec6a 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -14,7 +14,6 @@ import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubU import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./Jira/WebhookTypes"; import { JiraOAuthResult } from "./Jira/Types"; -import JoinedRoomsManager from "./Managers/JoinedRoomsManager"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; @@ -48,8 +47,7 @@ export class Bridge { private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; private readonly tokenStore: UserTokenStore; - private readonly botUsersManager; - private readonly joinedRoomsManager; + private readonly botUsersManager: BotUsersManager; private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); @@ -72,8 +70,7 @@ export class Bridge { this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); - this.joinedRoomsManager = new JoinedRoomsManager(); - this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as, this.joinedRoomsManager); + this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as); this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); @@ -135,8 +132,8 @@ export class Bridge { const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); log.debug(`Bot "${botUser.userId}" is joined to ${joinedRooms.length} rooms`); - for (const r of joinedRooms) { - this.joinedRoomsManager.addJoinedRoom(r, botUser.userId); + for (const roomId of joinedRooms) { + this.botUsersManager.onRoomJoin(botUser, roomId); } } @@ -700,7 +697,7 @@ export class Bridge { ); // Set up already joined rooms - await Promise.all(this.joinedRoomsManager.joinedRooms.map(async (roomId) => { + await Promise.all(this.botUsersManager.joinedRooms.map(async (roomId) => { log.debug("Fetching state for " + roomId); try { @@ -847,12 +844,16 @@ export class Bridge { private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) { const userId = matrixEvent.state_key; - if (!userId || !this.botUsersManager.isBotUser(userId)) { - // Not for one of our bots + if (!userId) { return; } - this.joinedRoomsManager.removeJoinedRoom(roomId, userId); + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomLeave(botUser, roomId); if (!this.connectionManager) { return; @@ -860,7 +861,7 @@ export class Bridge { // Remove all the connections for this room await this.connectionManager.removeConnectionsForRoom(roomId); - if (this.joinedRoomsManager.getBotsInRoom(roomId).length > 0) { + if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) { // If there are still bots in the room, recreate connections await this.connectionManager.createConnectionsForRoomId(roomId, true); } @@ -994,8 +995,7 @@ export class Bridge { // Not for one of our bots return; } - - this.joinedRoomsManager.addJoinedRoom(roomId, botUser.userId); + this.botUsersManager.onRoomJoin(botUser, roomId); if (this.config.encryption) { // Ensure crypto is aware of all members of this room before posting any messages, diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 90b053277..14805b5c4 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -2,7 +2,6 @@ import { Appservice, IAppserviceRegistration, Intent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { BridgeConfig } from "../Config/Config"; -import JoinedRoomsManager from "./JoinedRoomsManager"; const log = new Logger("BotUsersManager"); @@ -31,11 +30,13 @@ export default class BotUsersManager { // Map of user ID to config for all our configured bot users private _botUsers = new Map(); + // Map of room ID to set of bot users in the room + private _botsInRooms = new Map>(); + constructor( readonly config: BridgeConfig, readonly registration: IAppserviceRegistration, readonly as: Appservice, - readonly joinedRoomsManager: JoinedRoomsManager, ) { // Default bot user this._botUsers.set( @@ -75,12 +76,51 @@ export default class BotUsersManager { } } + /** + * Records a bot user having joined a room. + * + * @param botUser + * @param roomId + */ + onRoomJoin(botUser: BotUser, roomId: string): void { + log.info(`Bot user ${botUser.userId} joined room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.add(botUser); + this._botsInRooms.set(roomId, botUsers); + } + + /** + * Records a bot user having left a room. + * + * @param botUser + * @param roomId + */ + onRoomLeave(botUser: BotUser, roomId: string): void { + log.info(`Bot user ${botUser.userId} left room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.delete(botUser); + if (botUsers.size > 0) { + this._botsInRooms.set(roomId, botUsers); + } else { + this._botsInRooms.delete(roomId); + } + } + + /** + * Gets the list of room IDs where at least one bot is a member. + * + * @returns List of room IDs. + */ + get joinedRooms(): string[] { + return Array.from(this._botsInRooms.keys()); + } + /** * Gets the configured bot users, ordered by priority. * * @returns List of bot users. */ - get botUsers(): Readonly[] { + get botUsers(): BotUser[] { return Array.from(this._botUsers.values()) .sort(higherPriority) } @@ -90,7 +130,7 @@ export default class BotUsersManager { * * @param userId User ID to get. */ - getBotUser(userId: string): Readonly | undefined { + getBotUser(userId: string): BotUser | undefined { return this._botUsers.get(userId); } @@ -109,10 +149,8 @@ export default class BotUsersManager { * * @param roomId Room ID to get bots for. */ - getBotUsersInRoom(roomId: string): Readonly[] { - return this.joinedRoomsManager.getBotsInRoom(roomId) - .map(botUserId => this.getBotUser(botUserId)) - .filter((b): b is BotUser => b !== undefined) + getBotUsersInRoom(roomId: string): BotUser[] { + return Array.from(this._botsInRooms.get(roomId) || new Set()) .sort(higherPriority); } @@ -123,7 +161,7 @@ export default class BotUsersManager { * @param roomId Room ID to get a bot user for. * @param serviceType Optional service type for the bot. */ - getBotUserInRoom(roomId: string, serviceType?: string): Readonly | undefined { + getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined { const botUsersInRoom = this.getBotUsersInRoom(roomId); if (serviceType) { return botUsersInRoom.find(b => b.services.includes(serviceType)); @@ -137,7 +175,7 @@ export default class BotUsersManager { * * @param serviceType Service type for the bot. */ - getBotUserForService(serviceType: string): Readonly | undefined { + getBotUserForService(serviceType: string): BotUser | undefined { return this.botUsers.find(b => b.services.includes(serviceType)); } } diff --git a/src/Managers/JoinedRoomsManager.ts b/src/Managers/JoinedRoomsManager.ts deleted file mode 100644 index 8b86639ba..000000000 --- a/src/Managers/JoinedRoomsManager.ts +++ /dev/null @@ -1,63 +0,0 @@ -export default class JoinedRoomsManager { - // Map of room ID to set of our bot user IDs in the room - private readonly _botsInRooms: Map> = new Map(); - - /** - * Gets a map of the bot users in each room. - * - * @returns Map of room IDs to the list of bot user IDs in that room. - */ - get botsInRooms(): Map { - return new Map(Array.from( - this._botsInRooms, - ([k, v]) => [k, Array.from(v)] - )); - } - - /** - * Gets the list of room IDs where at least one bot is a member. - * - * @returns List of room IDs. - */ - get joinedRooms(): string[] { - return Array.from(this._botsInRooms.keys()); - } - - /** - * Adds a bot user ID to the set of bots in a room. - * - * @param roomId - * @param botUserId - */ - addJoinedRoom(roomId: string, botUserId: string): void{ - const userIds = this._botsInRooms.get(roomId) ?? new Set(); - userIds.add(botUserId); - this._botsInRooms.set(roomId, userIds); - } - - /** - * Removes a bot user ID from the set of bots in a room. - * - * @param roomId - * @param botUserId - */ - removeJoinedRoom(roomId: string, botUserId: string): void { - const userIds = this._botsInRooms.get(roomId) ?? new Set(); - userIds.delete(botUserId); - if (userIds.size > 0) { - this._botsInRooms.set(roomId, userIds); - } else { - this._botsInRooms.delete(roomId); - } - } - - /** - * Gets the list of user IDs for all bots in a room. - * - * @param roomId - * @returns List of user IDs for all bots in the room. - */ - getBotsInRoom(roomId: string): string[] { - return Array.from(this._botsInRooms.get(roomId) || new Set()); - } -} From 0b8555b4f08d0d0ac173ee5e98640ec9d3663902 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 17:28:49 -0500 Subject: [PATCH 40/52] Remove unused localpart from bot user class --- src/Bridge.ts | 2 +- src/Managers/BotUsersManager.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index e2db1ec6a..c665d1eec 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -70,7 +70,7 @@ export class Bridge { this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); - this.botUsersManager = new BotUsersManager(this.config, this.registration, this.as); + this.botUsersManager = new BotUsersManager(this.config, this.as); this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 14805b5c4..8f3f9bf48 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -8,7 +8,6 @@ const log = new Logger("BotUsersManager"); export class BotUser { constructor( private readonly as: Appservice, - readonly localpart: string, readonly userId: string, readonly services: string[], readonly prefix: string, @@ -35,7 +34,6 @@ export default class BotUsersManager { constructor( readonly config: BridgeConfig, - readonly registration: IAppserviceRegistration, readonly as: Appservice, ) { // Default bot user @@ -43,7 +41,6 @@ export default class BotUsersManager { this.as.botUserId, new BotUser( this.as, - registration.sender_localpart, this.as.botUserId, // Default bot can handle all services this.config.enabledServices, @@ -62,7 +59,6 @@ export default class BotUsersManager { botUserId, new BotUser( this.as, - bot.localpart, botUserId, [bot.service], bot.prefix, From 6bb62942c078ff554d464c0667e07eb6f0488d7e Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Wed, 21 Dec 2022 17:32:41 -0500 Subject: [PATCH 41/52] Refactor to pass in bot users manager --- src/App/BridgeApp.ts | 5 ++++- src/Bridge.ts | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 4dde28276..750e4b9bb 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -8,6 +8,7 @@ import { ListenerService } from "../ListenerService"; import { Logger } from "matrix-appservice-bridge"; import { LogService } from "matrix-bot-sdk"; import { getAppservice } from "../appservice"; +import BotUsersManager from "../Managers/BotUsersManager"; Logger.configure({console: "info"}); const log = new Logger("App"); @@ -35,7 +36,9 @@ async function start() { userNotificationWatcher.start(); } - const bridgeApp = new Bridge(config, listener, registration, appservice, storage); + const botUsersManager = new BotUsersManager(config, appservice); + + const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/Bridge.ts b/src/Bridge.ts index c665d1eec..cb1416605 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -47,7 +47,6 @@ export class Bridge { private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; private readonly tokenStore: UserTokenStore; - private readonly botUsersManager: BotUsersManager; private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); @@ -60,9 +59,9 @@ export class Bridge { constructor( private config: BridgeConfig, private readonly listener: ListenerService, - private readonly registration: IAppserviceRegistration, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, ) { this.queue = createMessageQueue(this.config.queue); this.messageClient = new MessageSenderClient(this.queue); @@ -70,7 +69,6 @@ export class Bridge { this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); - this.botUsersManager = new BotUsersManager(this.config, this.as); this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); From 9108414e1ce562288cdbb912ace54eee03f0ae59 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 12:40:57 -0500 Subject: [PATCH 42/52] Improve priority sort function Co-authored-by: Christian Paul --- src/Managers/BotUsersManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 8f3f9bf48..fbaffd9b3 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -23,7 +23,7 @@ export class BotUser { } // Sort bot users by highest priority first. -const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (a.priority < b.priority) ? 1 : -1; +const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (a.priority - b.priority); export default class BotUsersManager { // Map of user ID to config for all our configured bot users From 7aba6c106d78c005507520f77a0cf87bf6ada34a Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 12:44:19 -0500 Subject: [PATCH 43/52] Fix priority sort Higher priority should come first --- src/Managers/BotUsersManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index fbaffd9b3..28e4d9eb4 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -23,7 +23,7 @@ export class BotUser { } // Sort bot users by highest priority first. -const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (a.priority - b.priority); +const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (b.priority - a.priority); export default class BotUsersManager { // Map of user ID to config for all our configured bot users From 8cfb8a90e7ac1bacc62492b8c5663361ab711ea2 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 16:37:53 -0500 Subject: [PATCH 44/52] Add debug log when invites are rejected --- src/Bridge.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bridge.ts b/src/Bridge.ts index cb1416605..76dd96c29 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -816,6 +816,7 @@ export class Bridge { const botUser = this.botUsersManager.getBotUser(invitedUserId); if (!botUser) { // We got an invite but it's not a configured bot user, must be for a ghost user + log.debug(`Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`); const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; return client.kickUser(invitedUserId, roomId, "Bridge does not support DMing ghosts"); } @@ -827,6 +828,7 @@ export class Bridge { if (event.content.is_direct && botUser.userId !== this.as.botUserId) { // Service bots do not support direct messages (admin rooms) + log.debug(`Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`); return botUser.intent.underlyingClient.kickUser(botUser.userId, roomId, "This bot does not support admin rooms."); } From 6bb6f43ca0cea8ffd6750b5bae1a7feab94208a8 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 16:42:04 -0500 Subject: [PATCH 45/52] Use different state key for scoped setup widgets --- src/Widgets/SetupWidget.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index 65792b2d4..a0cdf28de 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -19,7 +19,14 @@ export class SetupWidget { static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise { // If this is for a single service, scope the widget const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined; - if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config", serviceScope)) { + if (await SetupWidget.createWidgetInRoom( + roomId, + botIntent, + config, + HookshotWidgetKind.RoomConfiguration, + `hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`, + serviceScope, + )) { await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`); return true; } From 919447b9e2cba2978423cb0b157f902aa5388d92 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 17:05:04 -0500 Subject: [PATCH 46/52] Use different subtitles to differentiate service bots setup widgets --- src/Widgets/SetupWidget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index a0cdf28de..64b00d295 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -69,7 +69,7 @@ export class SetupWidget { { "creatorUserId": botIntent.userId, "data": { - "title": config.branding.widgetTitle + "title": serviceScope ? serviceScope : config.branding.widgetTitle, }, "id": stateKey, "name": config.branding.widgetTitle, From 855ac0af72ac10710f654622f7932b746067d487 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 17:24:32 -0500 Subject: [PATCH 47/52] Refactor bot user setup into bot users manager --- src/Bridge.ts | 37 +----------------------------- src/Managers/BotUsersManager.ts | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 76dd96c29..0bceb9a4f 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -98,42 +98,7 @@ export class Bridge { } } - log.info("Ensuring bot users are set up..."); - - // Register and set profiles for all our bots - for (const botUser of this.botUsersManager.botUsers) { - // Ensure the bot is registered - log.debug(`Ensuring '${botUser.userId}' is registered`); - await botUser.intent.ensureRegistered(); - - // Set up the bot profile - let profile; - try { - profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); - } catch { - profile = {} - } - if (botUser.avatar && profile.avatar_url !== botUser.avatar) { - log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); - await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); - } - if (botUser.displayname && profile.displayname !== botUser.displayname) { - log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); - await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); - } - } - - log.info("Fetching joined rooms..."); - - // Collect joined rooms for all our bots - for (const botUser of this.botUsersManager.botUsers) { - const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); - log.debug(`Bot "${botUser.userId}" is joined to ${joinedRooms.length} rooms`); - - for (const roomId of joinedRooms) { - this.botUsersManager.onRoomJoin(botUser, roomId); - } - } + await this.botUsersManager.start(); await this.config.prefillMembershipCache(this.as.botClient); diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index 28e4d9eb4..b1b4c84e6 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -72,6 +72,46 @@ export default class BotUsersManager { } } + async start(): Promise { + await this.ensureProfiles(); + await this.getJoinedRooms(); + } + + private async ensureProfiles(): Promise { + log.info("Ensuring bot users are set up..."); + for (const botUser of this.botUsers) { + // Ensure the bot is registered + log.debug(`Ensuring bot user ${botUser.userId} is registered`); + await botUser.intent.ensureRegistered(); + + // Set up the bot profile + let profile; + try { + profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); + } catch { + profile = {} + } + if (botUser.avatar && profile.avatar_url !== botUser.avatar) { + log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); + await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); + } + if (botUser.displayname && profile.displayname !== botUser.displayname) { + log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); + await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); + } + } + } + + private async getJoinedRooms(): Promise { + log.info("Getting joined rooms..."); + for (const botUser of this.botUsers) { + const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); + for (const roomId of joinedRooms) { + this.onRoomJoin(botUser, roomId); + } + } + } + /** * Records a bot user having joined a room. * From 50851679c9b0a7e113fb96fa259d2114ec0c9d76 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 17:37:14 -0500 Subject: [PATCH 48/52] Refactor to reduce duplication in widget API --- src/Widgets/BridgeWidgetApi.ts | 38 ++++++++++++++-------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 0846efcfe..5f253457a 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -7,7 +7,7 @@ import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { ConnectionManager } from "../ConnectionManager"; -import BotUsersManager from "../Managers/BotUsersManager"; +import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; @@ -56,6 +56,14 @@ export class BridgeWidgetApi { this.api.addRoute("get", '/v1/targets/:type', wrapHandler(this.getConnectionTargets)); } + private getBotUserInRoom(roomId: string, serviceType: string): BotUser { + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + return botUser; + } + private async getRoomFromRequest(req: ProvisioningRequest): Promise { const room = [...this.adminRooms.values()].find(r => r.userId === req.userId); if (!room) { @@ -97,12 +105,8 @@ export class BridgeWidgetApi { const roomId = req.params.roomId; const serviceType = req.params.service; - const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", botUser.intent); + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent); const allConnections = this.connMan.getAllConnectionsForRoom(roomId); const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); const serviceFilter = req.params.service; @@ -146,12 +150,8 @@ export class BridgeWidgetApi { } const serviceType = connectionType.ServiceCategory; - const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", botUser.intent); + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); @@ -179,11 +179,7 @@ export class BridgeWidgetApi { const serviceType = req.params.type; const connectionId = req.params.connectionId; - const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - + const botUser = this.getBotUserInRoom(roomId, serviceType); await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { @@ -205,11 +201,7 @@ export class BridgeWidgetApi { const serviceType = req.params.type; const connectionId = req.params.connectionId; - const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); - if (!botUser) { - throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); - } - + const botUser = this.getBotUserInRoom(roomId, serviceType); await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { From ad953b22d20ee73915a96878a3dc9e891ed37c6b Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 3 Jan 2023 17:42:39 -0500 Subject: [PATCH 49/52] Consistent room ID and intent args order --- src/ConnectionManager.ts | 4 ++-- src/Widgets/BridgeWidgetApi.ts | 2 +- src/provisioning/provisioner.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index 8bf886ee3..23ccd73b0 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -65,16 +65,16 @@ export class ConnectionManager extends EventEmitter { /** * Used by the provisioner API to create new connections on behalf of users. * - * @param intent Bot user intent to create the connection with. * @param roomId The target Matrix room. + * @param intent Bot user intent to create the connection with. * @param userId The requesting Matrix user. * @param connectionType The connection declaration to provision. * @param data The data corresponding to the connection state. This will be validated. * @returns The resulting connection. */ public async provisionConnection( - intent: Intent, roomId: string, + intent: Intent, userId: string, connectionType: ConnectionDeclaration, data: Record, diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 5f253457a..3f770db72 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -157,7 +157,7 @@ export class BridgeWidgetApi { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(botUser.intent, roomId, req.userId, connectionType, req.body); + const result = await this.connMan.provisionConnection(roomId, botUser.intent, req.userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 816c46f57..eac23c88a 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -159,7 +159,7 @@ export class Provisioner { throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); } - const result = await this.connMan.provisionConnection(botUser.intent, roomId, userId, connectionType, req.body); + const result = await this.connMan.provisionConnection(roomId, botUser.intent, userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } From a32860dee8721844a2dff333cf11fbf0a0469f45 Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Tue, 10 Jan 2023 12:04:53 -0500 Subject: [PATCH 50/52] Add docs and update changelog --- changelog.d/573.feature | 2 +- docs/advanced/service_bots.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 docs/advanced/service_bots.md diff --git a/changelog.d/573.feature b/changelog.d/573.feature index df8f68f63..95dd9f0d0 100644 --- a/changelog.d/573.feature +++ b/changelog.d/573.feature @@ -1 +1 @@ -Implement "service bots", which are additional bot users configured to handle a particular service. +Add support for additional bot users called "service bots" which handle a particular connection type, so that different services can be used through different bot users. diff --git a/docs/advanced/service_bots.md b/docs/advanced/service_bots.md new file mode 100644 index 000000000..f2e73c313 --- /dev/null +++ b/docs/advanced/service_bots.md @@ -0,0 +1,28 @@ +# Service Bots + +Hookshot supports additional bot users called "service bots" which handle a particular connection type +(in addition to the default bot user which can handle any connection type). +These bots can coexist in a room, each handling a different service. + +## Configuration + +Service bots can be given a different localpart, display name, avatar, and command prefix. +They will only handle connections for the specified service, which can be one of: +* `feeds` - [Feeds](../setup/feeds.md) +* `figma` - [Figma](../setup/figma.md) +* `generic` - [Webhooks](../setup/webhooks.md) +* `github` - [GitHub](../setup/github.md) +* `gitlab` - [GitLab](../setup/gitlab.md) +* `jira` - [Jira](../setup/jira.md) + +For example with this configuration: +```yaml +serviceBots: + - localpart: feeds + displayname: Feeds + avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + prefix: "!feeds" + service: feeds +``` + +There will be a bot user `@feeds:example.com` which responds to commands prefixed with `!feeds`, and only handles feeds connections. From 0b858dbcf2452e1c31edc1dce54f56144fcbbc1e Mon Sep 17 00:00:00 2001 From: Justin Carlson Date: Thu, 12 Jan 2023 17:30:38 -0500 Subject: [PATCH 51/52] Add overrideUserId deprecation warning --- src/Config/Config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 7e7f8ef47..f250a66a2 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -640,6 +640,10 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. if (this.encryption && !this.queue.port) { throw new ConfigError("queue.port", "You must enable redis support for encryption to work."); } + + if (this.figma?.overrideUserId) { + log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead."); + } } public async prefillMembershipCache(client: MatrixClient) { From 57503237007a0433de6c5489818b26714846afbc Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 13 Jan 2023 15:29:21 +0000 Subject: [PATCH 52/52] Add service bots link --- docs/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e2837e201..9f689f094 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -27,3 +27,4 @@ - [Workers](./advanced/workers.md) - [🔒 Encryption](./advanced/encryption.md) - [🪀 Widgets](./advanced/widgets.md) +- [Service Bots](./advanced/service_bots.md)