diff --git a/changelog.d/573.feature b/changelog.d/573.feature new file mode 100644 index 000000000..95dd9f0d0 --- /dev/null +++ b/changelog.d/573.feature @@ -0,0 +1 @@ +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/config.sample.yml b/config.sample.yml index a16400c6e..2122e3e19 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -100,8 +100,16 @@ passFile: bot: # (Optional) Define profile information for the bot user # - displayname: GitHub Bot + displayname: Hookshot Bot avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d +serviceBots: + # (Optional) Define additional bot users for specific services + # + - localpart: feeds + displayname: Feeds + avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + prefix: "!feeds" + service: feeds metrics: # (Optional) Prometheus metrics support # 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) 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. diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 703fcde1f..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, 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 97cf7077b..d0ac0a51a 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, Intent } 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"; @@ -50,6 +51,7 @@ export class Bridge { private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); + private widgetApi?: BridgeWidgetApi; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -60,6 +62,7 @@ export class Bridge { private readonly listener: ListenerService, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, ) { this.queue = createMessageQueue(this.config.queue); this.messageClient = new MessageSenderClient(this.queue); @@ -67,6 +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.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})); } @@ -82,20 +86,21 @@ export class Bridge { await this.storage.connect?.(); await this.queue.connect?.(); - // Fetch all room state - let joinedRooms: string[]|undefined; - while(joinedRooms === undefined) { + log.info("Ensuring homeserver can be reached..."); + let reached = false; + while (!reached) { 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); + // 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)); } } - + + await this.botUsersManager.start(); + await this.config.prefillMembershipCache(this.as.botClient); if (this.config.github) { @@ -112,20 +117,28 @@ 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, ); } - + if (this.config.provisioning) { const routers = []; if (this.config.jira) { @@ -145,7 +158,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) => { @@ -266,114 +285,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), ); @@ -419,10 +438,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), @@ -430,19 +449,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), ); @@ -458,10 +477,16 @@ 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'); + } + try { // If we don't have an existing connection for this discussion (likely), then create one. discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom( this.as, + botUser.intent, null, data.repository.owner.login, data.repository.name, @@ -486,7 +511,7 @@ export class Bridge { } }) }); - + this.bindHandlerToQueue( "jira.issue_created", (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project), @@ -553,7 +578,7 @@ export class Bridge { }); }); - + this.queue.on("generic-webhook.event", async (msg) => { const { data, messageId } = msg; const connections = connManager.getConnectionsForGenericWebhook(data.hookId); @@ -635,30 +660,13 @@ 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); - } - } const queue = new PQueue({ concurrency: 2, }); - queue.addAll(joinedRooms.map(((roomId) => async () => { + // Set up already joined rooms + await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => { log.debug("Fetching state for " + roomId); + try { await connManager.createConnectionsForRoomId(roomId, false); } catch (ex) { @@ -666,13 +674,19 @@ export class Bridge { 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; + } + // TODO: Refactor this to be a connection try { - let accountData = await this.as.botClient.getSafeRoomAccountData( + let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { - accountData = await this.as.botClient.getSafeRoomAccountData( + accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( LEGACY_BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { @@ -680,18 +694,18 @@ export class Bridge { return; } else { // Upgrade the room - await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); } } let notifContent; try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.StateType, "", ); } catch (ex) { try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.LegacyStateType, "", ); } @@ -699,14 +713,14 @@ export class Bridge { // No state yet } } - const adminRoom = await this.setUpAdminRoom(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()}`); } catch (ex) { log.error(`Failed to set up admin room ${roomId}:`, ex); } - }))); + })); // Handle spaces for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) { @@ -719,16 +733,17 @@ 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, apps[0], this.connectionManager, - this.as.botIntent, + this.botUsersManager, + this.as, ); - + } if (this.provisioningApi) { this.listener.bindResource('provisioning', this.provisioningApi.expressRouter); @@ -763,36 +778,63 @@ 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}`); + + 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"); } // 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 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) + 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."); } - await retry(() => this.as.botIntent.joinRoom(roomId), 5); + // Accept the invite + await retry(() => botUser.intent.joinRoom(roomId), 5); if (event.content.is_direct) { - await this.as.botClient.setRoomAccountData( + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, ); } } - 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) { return; } - // 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}`); + + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomLeave(botUser, roomId); + + if (!this.connectionManager) { + return; + } + + // Remove all the connections for this room + await this.connectionManager.removeConnectionsForRoom(roomId); + if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) { + // If there are still bots in the room, recreate connections + await this.connectionManager.createConnectionsForRoomId(roomId, true); } } @@ -838,13 +880,23 @@ 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, + botUser.services, + [ + ...botUser.services, + this.config.widgets?.roomSetupWidget ? "widget" : "", + ], { config: this.config, as: this.as, + intent: botUser.intent, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, messageClient: this.messageClient, @@ -852,12 +904,16 @@ 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), - ) - ).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; @@ -900,23 +956,30 @@ 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) { + return; + } + + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots return; } + this.botUsersManager.onRoomJoin(botUser, roomId); 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 botUser.intent.underlyingClient.crypto.onRoomJoin(roomId); } - const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (adminAccountData) { - const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent()); - await this.as.botClient.setRoomAccountData( + const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); } @@ -926,20 +989,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 botUser.intent.underlyingClient.userHasPowerLevelFor( + botUser.intent.userId, + roomId, + "im.vector.modular.widgets", + true, + ); + if (!hasPowerlevel) { + await botUser.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); + // Set up the widget + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); } } catch (ex) { log.error(`Failed to setup new widget for room`, ex); @@ -988,28 +1059,31 @@ 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`); + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); + } catch (ex) { + log.error(`Failed to create setup widget for ${roomId}`, ex); + } } } - } + } return; } // 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; } @@ -1169,16 +1243,16 @@ 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, ); @@ -1194,23 +1268,28 @@ 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)); 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 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) => { @@ -1219,25 +1298,26 @@ 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, instance, res, issueInfo.projects, this.as, - this.tokenStore, + 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/Config/Config.ts b/src/Config/Config.ts index bd9aa4de6..f250a66a2 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; + service: string; +} + export interface BridgeConfigProvisioning { bindAddress?: string; port?: number; @@ -425,6 +433,7 @@ export interface BridgeConfigMetrics { export interface BridgeConfigRoot { bot?: BridgeConfigBot; + serviceBots?: BridgeConfigServiceBot[]; bridge: BridgeConfigBridge; experimentalEncryption?: BridgeConfigEncryption; figma?: BridgeConfigFigma; @@ -478,6 +487,8 @@ export class BridgeConfig { public readonly feeds?: BridgeConfigFeeds; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; + @configKey("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) @@ -513,6 +524,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, @@ -525,7 +537,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)) { @@ -547,7 +559,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }]; 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.`); } @@ -574,7 +586,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }); 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'], @@ -590,7 +602,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }) 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'], @@ -599,7 +611,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }) 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,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) { @@ -637,7 +653,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. 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) { @@ -656,6 +672,29 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]); } + public get enabledServices(): string[] { + const services = []; + if (this.feeds && this.feeds.enabled) { + services.push("feeds"); + } + if (this.figma) { + services.push("figma"); + } + if (this.generic && this.generic.enabled) { + services.push("generic"); + } + if (this.github) { + services.push("github"); + } + if (this.gitlab) { + services.push("gitlab"); + } + if (this.jira) { + services.push("jira"); + } + return services; + } + public getPublicConfigForService(serviceName: string): Record { let config: undefined|Record; switch (serviceName) { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index e44983293..da0985e77 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", + service: "feeds", + }, + ], github: { auth: { id: 123, diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index b83b9990b..8ef423419 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -4,8 +4,8 @@ * Manages connections between Matrix rooms and the remote side. */ +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "./api"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { CommentProcessor } from "./CommentProcessor"; import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; @@ -18,6 +18,7 @@ import { JiraProject, JiraVersion } from "./Jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; import { UserTokenStore } from "./UserTokenStore"; +import BotUsersManager from "./Managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; import EventEmitter from "events"; @@ -42,6 +43,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(); @@ -66,21 +68,29 @@ export class ConnectionManager extends EventEmitter { /** * Used by the provisioner API to create new connections on behalf of users. + * * @param roomId The target Matrix room. + * @param intent Bot user intent to create the connection with. * @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. */ - public async provisionConnection(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)); + public async provisionConnection( + roomId: string, + intent: Intent, + userId: string, + connectionType: ConnectionDeclaration, + data: Record, + ) { + 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); } const result = await connectionType.provisionConnection(roomId, userId, data, { as: this.as, + intent: intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -99,14 +109,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; @@ -121,30 +132,35 @@ 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'); + } + return !this.verifyStateEvent(connection.roomId, botUser.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}`); } @@ -156,15 +172,25 @@ 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; } - if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) { + + // 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`); + throw Error('Could not find a bot to handle this connection'); + } + + if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) { return; } + return connectionType.createConnectionForState(roomId, state, { as: this.as, + intent: botUser.intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -175,10 +201,15 @@ export class ConnectionManager extends EventEmitter { } public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) { - let connectionCreated = false; + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when creating connections`); + return; + } + // This endpoint can be heavy, wrap it in pillows. const state = await retry( - () => this.as.botClient.getRoomState(roomId), + () => botUser.intent.underlyingClient.getRoomState(roomId), GET_STATE_ATTEMPTS, GET_STATE_TIMEOUT_MS, retryMatrixErrorFilter @@ -190,13 +221,11 @@ export class ConnectionManager extends EventEmitter { 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)[] { @@ -289,12 +318,16 @@ 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[]; } + 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); } @@ -344,7 +377,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}`); @@ -363,8 +396,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) { diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 5b008dbc4..a98fdaac1 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, {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 { 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, diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 211dcf876..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,25 +174,22 @@ 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); } } 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, @@ -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 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/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 +} 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 +} 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 +} diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 57060b941..ac3ec69c9 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"; @@ -91,12 +91,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" | @@ -107,7 +107,7 @@ type AllowedEventsNames = "release.drafted" | "release" | "workflow" | - "workflow.run" | + "workflow.run" | "workflow.run.success" | "workflow.run.failure" | "workflow.run.neutral" | @@ -194,7 +194,7 @@ const ConnectionStateSchema = { nullable: true, maxLength: 24, }, - showIssueRoomLink: { + showIssueRoomLink: { type: "boolean", nullable: true, }, @@ -272,7 +272,7 @@ const ConnectionStateSchema = { additionalProperties: true } as JSONSchemaType; -type ReactionOptions = +type ReactionOptions = | "+1" | "-1" | "laugh" @@ -356,7 +356,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'); } @@ -390,10 +390,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 { @@ -498,22 +498,25 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>(); - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, state: ConnectionValidatedState, private readonly tokenStore: UserTokenStore, stateKey: string, private readonly githubInstance: GithubInstance, private readonly config: BridgeConfigGitHub, - ) { + ) { super( roomId, stateKey, GitHubRepoConnection.CanonicalEventType, state, - as.botClient, + intent.underlyingClient, GitHubRepoConnection.botCommands, GitHubRepoConnection.helpMessage, + ["github"], "!gh", "github", ); @@ -607,7 +610,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 { @@ -806,7 +809,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}`: ""), @@ -880,7 +883,7 @@ export class GitHubRepoConnection extends CommandConnection { const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] }; @@ -928,9 +931,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}`: ""), @@ -987,8 +990,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, @@ -1011,7 +1014,7 @@ export class GitHubRepoConnection extends CommandConnection, { - 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 +} diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index f86234931..6ce9bf86c 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; } @@ -141,13 +163,17 @@ 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); } 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 +204,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 +213,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 +232,4 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public toString() { return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`; } -} \ No newline at end of file +} diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 6ed000a4b..2a1aa69d5 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"; @@ -59,7 +59,7 @@ const MRRCOMMENT_DEBOUNCE_MS = 5000; export type GitLabRepoResponseItem = GetConnectionsResponseItem; -type AllowedEventsNames = +type AllowedEventsNames = "merge_request.open" | "merge_request.close" | "merge_request.merge" | @@ -68,7 +68,7 @@ type AllowedEventsNames = "merge_request.review.comments" | `merge_request.${string}` | "merge_request" | - "tag_push" | + "tag_push" | "push" | "wiki" | `wiki.${string}` | @@ -170,7 +170,7 @@ export class GitLabRepoConnection extends CommandConnection MatrixMessageContent; static ServiceCategory = "gitlab"; @@ -198,7 +198,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'); } @@ -207,10 +207,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'); } @@ -234,11 +234,11 @@ export class GitLabRepoConnection extends CommandConnection c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path); @@ -278,7 +278,7 @@ export class GitLabRepoConnection extends CommandConnection ({ state: { @@ -340,21 +340,23 @@ 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: ConnectionStateValidated, private readonly tokenStore: UserTokenStore, - private readonly instance: GitLabInstance - ) { + private readonly instance: GitLabInstance, + ) { super( roomId, stateKey, GitLabRepoConnection.CanonicalEventType, state, - as.botClient, + intent.underlyingClient, GitLabRepoConnection.botCommands, GitLabRepoConnection.helpMessage, + ["gitlab"], "!gl", "gitlab", ) @@ -398,7 +400,7 @@ export class GitLabRepoConnection extends CommandConnection PUSH_MAX_COMMITS; const displayedCommits = tooManyCommits ? 1 : Math.min(event.total_commits_count, PUSH_MAX_COMMITS); - + // Take the top 5 commits. The array is ordered in reverse. const commits = event.commits.reverse().slice(0,displayedCommits).map(commit => { return `[\`${commit.id.slice(0,8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`; @@ -603,14 +605,14 @@ 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), @@ -746,7 +748,7 @@ ${data.description}`; } this.debounceMergeRequestReview( event.user, - event.object_attributes, + event.object_attributes, event.project, { commentCount: 0, @@ -810,7 +812,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.enabledHooks = this.state.enableHooks; } @@ -819,11 +821,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 } diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 87961b724..cc19aa076 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/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 1df3c0a3e..544dcdfaa 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, {as, tokenStore, config}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record, {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,36 +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), @@ -498,11 +500,11 @@ export class JiraProjectConnection extends 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 getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise, + 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 +90,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 +116,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 +148,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 +168,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 +191,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 +203,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: "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."); @@ -215,9 +221,9 @@ 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.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 +241,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 +266,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 +283,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 +292,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 +313,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 +322,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, this.serviceTypes)) { + await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); } } @@ -325,10 +331,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."); } } diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts new file mode 100644 index 000000000..b1b4c84e6 --- /dev/null +++ b/src/Managers/BotUsersManager.ts @@ -0,0 +1,217 @@ +import { Appservice, IAppserviceRegistration, Intent } from "matrix-bot-sdk"; +import { Logger } from "matrix-appservice-bridge"; + +import { BridgeConfig } from "../Config/Config"; + +const log = new Logger("BotUsersManager"); + +export class BotUser { + constructor( + private readonly as: Appservice, + 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. +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 + 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 as: Appservice, + ) { + // Default bot user + this._botUsers.set( + this.as.botUserId, + new BotUser( + this.as, + 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 botUserId = this.as.getUserId(bot.localpart); + this._botUsers.set( + botUserId, + new BotUser( + this.as, + botUserId, + [bot.service], + bot.prefix, + // Service bots should handle commands first + 1, + bot.avatar, + bot.displayname, + ) + ); + }); + } + } + + 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. + * + * @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(): BotUser[] { + return Array.from(this._botUsers.values()) + .sort(higherPriority) + } + + /** + * Gets a configured bot user by user ID. + * + * @param userId User ID to get. + */ + getBotUser(userId: string): BotUser | 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); + } + + /** + * Gets all the bot users in a room, ordered by priority. + * + * @param roomId Room ID to get bots for. + */ + getBotUsersInRoom(roomId: string): BotUser[] { + return Array.from(this._botsInRooms.get(roomId) || new Set()) + .sort(higherPriority); + } + + /** + * 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): BotUser | undefined { + const botUsersInRoom = this.getBotUsersInRoom(roomId); + if (serviceType) { + return botUsersInRoom.find(b => b.services.includes(serviceType)); + } else { + 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): BotUser | undefined { + return this.botUsers.find(b => b.services.includes(serviceType)); + } +} diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 5f7ef991e..3f770db72 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, {BotUser} 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, @@ -54,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) { @@ -92,9 +102,13 @@ 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.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; const connections = allConnections.map(c => c.getProvisionerDetails?.(true)) .filter(c => !!c) @@ -128,13 +142,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 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.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); } 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(roomId, botUser.intent, req.userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } @@ -152,15 +175,20 @@ 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.getBotUserInRoom(roomId, serviceType); + 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); } 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 +197,12 @@ 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.getBotUserInRoom(roomId, serviceType); + 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/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index ad97c02d4..64b00d295 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -16,15 +16,31 @@ 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_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`, + 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."); @@ -53,15 +69,15 @@ export class SetupWidget { { "creatorUserId": botIntent.userId, "data": { - "title": config.branding.widgetTitle + "title": serviceScope ? serviceScope : config.branding.widgetTitle, }, "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 +} diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 005ac944b..eac23c88a 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,14 @@ 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); + } + try { - await assertUserPermissionsInRoom(userId, roomId, requiredPermission, this.intent); + await assertUserPermissionsInRoom(userId, roomId, requiredPermission, botUser.intent); next(); } catch (ex) { next(ex); @@ -130,22 +138,38 @@ 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 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") { 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 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'); } + 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); } } 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 cad13837d..5213b7969 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -40,10 +40,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", @@ -56,7 +58,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", () => { @@ -127,15 +129,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({ @@ -149,10 +151,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({ @@ -164,10 +166,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({ @@ -179,7 +181,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 9e69e516c..eed830424 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -38,10 +38,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", @@ -52,7 +53,7 @@ function createConnection(state: Record = {}, isExistingState=f url: "https://gitlab.example.com" }, ); - return {connection, as}; + return {connection, intent}; } describe("GitLabRepoConnection", () => { @@ -126,15 +127,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({ @@ -145,10 +146,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({ @@ -157,10 +158,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({ @@ -169,7 +170,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; }