diff --git a/README.md b/README.md index 957d0ad6..7ccf340c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,15 @@ In addition to all features of the upstream bridge, this fork adds the following - Fix issue that caused files sent to a thread on Slack to be posted on the main timeline on Matrix [[#20](https://github.com/Automattic/matrix-appservice-slack/pull/20)] [[upstream issue #671](https://github.com/matrix-org/matrix-appservice-slack/issues/671)] - Fix issue that caused channel name to not be displayed in the output of the `link` and `list` admin commands [[upstream fix #756](https://github.com/matrix-org/matrix-appservice-slack/pull/756)] +## Team Sync Modifications + +- Don't update Slack ghost users upon every message as they are already handled in realtime under team sync. +- Fix bug in membership change calculation when syncing channels upon boot. +- Handle `channel_archive` slack event. +- Define `rooms` field under teamsync config for who should be the creator, mods and admins in new rooms created by team sync. +- Tweak message that gets posted in a new channel to suggest inviting `matrixbridge` Slack app. +- Notify admins in admin room for bridge when bridge initialises upon boot, when a Slack channel is created/archived/deleted and unlinking of bridge fails upon channel archive/delete event. + ## Usage This fork is a drop-in replacement for the upstream bridge, so the setup instructions are the same as upstream. The only difference is of course that you need to get the code from this fork: diff --git a/src/BridgedRoom.ts b/src/BridgedRoom.ts index db9f8e48..e1bda58c 100644 --- a/src/BridgedRoom.ts +++ b/src/BridgedRoom.ts @@ -718,11 +718,23 @@ export class BridgedRoom { } try { const ghost = await this.main.ghostStore.getForSlackMessage(message, this.slackTeamId); - const ghostChanged = await ghost.update(message, this.SlackClient); await ghost.cancelTyping(this.MatrixRoomId); // If they were typing, stop them from doing that. - if (ghostChanged) { - await this.main.fixDMMetadata(this, ghost); + + let isTeamSyncEnabledForUsers = false; + for (const team in this.main.config.team_sync) { + if (team === "all" || team === this.slackTeamId ) { + if (this.main.config.team_sync[team].users?.enabled) { + isTeamSyncEnabledForUsers = true; + } + } } + if (!isTeamSyncEnabledForUsers) { + const ghostChanged = await ghost.update(message, this.SlackClient); + if (ghostChanged) { + await this.main.fixDMMetadata(this, ghost); + } + } + this.slackSendLock = this.slackSendLock.then(() => { // Check again if (!isMessageChangedEvent && this.recentSlackMessages.includes(message.ts)) { diff --git a/src/Main.ts b/src/Main.ts index 629e3048..edb5168f 100644 --- a/src/Main.ts +++ b/src/Main.ts @@ -640,6 +640,19 @@ export class Main { return userIds.filter((i) => i.match(regexp)); } + public async listGhostAndMappedUsers(roomId: string): Promise { + const userIds = await this.listAllUsers(roomId); + const mappedUsernames = await this.datastore.getAllMatrixUsernames(); + + const mappedUsersSet = new Set(); + for (const mappedUsername of mappedUsernames ?? []) { + mappedUsersSet.add("@" + mappedUsername + ":" + this.config.homeserver.server_name); + } + + const regexp = new RegExp("^@" + this.config.username_prefix); + return userIds.filter((userId) => mappedUsersSet.has(userId) || userId.match(regexp)); + } + public async drainAndLeaveMatrixRoom(roomId: string): Promise { const userIds = await this.listGhostUsers(roomId); log.info(`Draining ${userIds.length} ghosts from ${roomId}`); @@ -1289,6 +1302,8 @@ export class Main { } log.info("Bridge initialised"); + await this.notifyAdmins("Bridge initialised"); + this.ready = true; return port; } @@ -1771,4 +1786,16 @@ export class Main { log.info("Enabled RTM"); } + public async notifyAdmins(message: string) { + if (this.config.matrix_admin_room) { + try { + await this.botIntent.sendMessage(this.config.matrix_admin_room, { + msgtype: "m.notice", + body: message, + }); + } catch(ex) { + log.warn("failed to notify admins", message); + } + } + } } diff --git a/src/SlackEventHandler.ts b/src/SlackEventHandler.ts index 4e15d5d7..82dbeceb 100644 --- a/src/SlackEventHandler.ts +++ b/src/SlackEventHandler.ts @@ -113,8 +113,8 @@ export class SlackEventHandler extends BaseSlackHandler { * to events in order to handle them. */ protected static SUPPORTED_EVENTS: string[] = ["message", "reaction_added", "reaction_removed", - "team_domain_change", "channel_rename", "user_change", "user_typing", "member_joined_channel", - "channel_created", "channel_deleted", "team_join"]; + "team_domain_change", "channel_rename", "user_change", "user_typing", "member_joined_channel", "member_left_channel", + "channel_created", "channel_deleted", "channel_archive", "team_join"]; constructor(main: Main) { super(main); } @@ -211,6 +211,7 @@ export class SlackEventHandler extends BaseSlackHandler { break; case "channel_created": case "channel_deleted": + case "channel_archive": case "user_change": case "team_join": await this.handleTeamSyncEvent(event as ISlackTeamSyncEvent, teamId); @@ -367,6 +368,8 @@ export class SlackEventHandler extends BaseSlackHandler { await this.main.teamSyncer.onChannelAdded(teamId, eventDetails.channel.id, eventDetails.channel.name, eventDetails.channel.creator); } else if (event.type === "channel_deleted") { await this.main.teamSyncer.onChannelDeleted(teamId, event.channel); + } else if (event.type === "channel_archive") { + await this.main.teamSyncer.onChannelArchived(teamId, event.channel); } else if (event.type === "team_join" || event.type === "user_change") { const user = event.user!; const domain = (await this.main.datastore.getTeam(teamId))!.domain; diff --git a/src/TeamSyncer.ts b/src/TeamSyncer.ts index 44869c85..3d55eb97 100644 --- a/src/TeamSyncer.ts +++ b/src/TeamSyncer.ts @@ -41,6 +41,11 @@ export interface ITeamSyncConfig { users?: { enabled: boolean; }; + rooms?: { + creator?: string; + administrators?: string[]; // promote these users as admins + moderators?: string[]; // promote these users as moderators + } } const TEAM_SYNC_CONCURRENCY = 1; @@ -55,12 +60,14 @@ const TEAM_SYNC_FAILSAFE = 10; */ export class TeamSyncer { private teamConfigs: {[teamId: string]: ITeamSyncConfig} = {}; + private readonly adminRoom?: string; constructor(private main: Main) { const config = main.config; if (!config.team_sync) { throw Error("team_sync is not defined in the config"); } // Apply defaults to configs + this.adminRoom = config.matrix_admin_room; this.teamConfigs = config.team_sync; for (const teamConfig of Object.values(this.teamConfigs)) { if (teamConfig.channels?.enabled) { @@ -224,6 +231,14 @@ export class TeamSyncer { const client = await this.main.clientFactory.getTeamClient(teamId); const { channel } = (await client.conversations.info({ channel: channelId })) as ConversationsInfoResponse; await this.syncChannel(teamId, channel); + const room = this.main.rooms.getBySlackChannelId(channelId); + if (!room) { + log.warn(`No bridged room found for new channel (${channelId}) after sync`); + await this.main.notifyAdmins(`${teamId} created channel ${channelId} but problem creating a bridge`); + return; + } + + await this.main.notifyAdmins(`${teamId} created channel ${channelId}, bridged room: ${room.MatrixRoomId}`); } public async onDiscoveredPrivateChannel(teamId: string, client: WebClient, chanInfo: ConversationsInfoResponse): Promise { @@ -368,63 +383,103 @@ export class TeamSyncer { return; } + await this.main.notifyAdmins(`${teamId} removed channel ${channelId}, bridged room: ${room.MatrixRoomId}`); + await this.shutDownBridgedRoom("deleted", room.MatrixRoomId); + } + + public async onChannelArchived(teamId: string, channelId: string): Promise { + log.info(`${teamId} archived channel ${channelId}`); + if (!this.getTeamSyncConfig(teamId, "channel", channelId)) { + return; + } + const room = this.main.rooms.getBySlackChannelId(channelId); + if (!room) { + log.warn("Not unlinking channel, no room found"); + return; + } + + await this.main.notifyAdmins(`${teamId} archived channel ${channelId}, bridged room: ${room.MatrixRoomId}`); + await this.shutDownBridgedRoom("archived", room.MatrixRoomId); + } + + public async shutDownBridgedRoom(reason: string, roomId: string) { try { - await this.main.botIntent.sendMessage(room.MatrixRoomId, { + await this.main.botIntent.sendMessage(roomId, { msgtype: "m.notice", - body: "The Slack channel bridged to this room has been deleted.", + body: `The Slack channel bridged to this room has been '${reason}'.`, }); } catch (ex) { - log.warn("Failed to send deletion notice into the room:", ex); + log.warn(`Failed to send '${reason}' notice into the room:`, ex); } - // Hide deleted channels in the room directory. + // Hide from room directory. try { - await this.main.botIntent.setRoomDirectoryVisibility(room.MatrixRoomId, "private"); + await this.main.botIntent.setRoomDirectoryVisibility(roomId, "private"); } catch (ex) { log.warn("Failed to hide room from the room directory:", ex); } try { - await this.main.actionUnlink({ matrix_room_id: room.MatrixRoomId }); + await this.main.actionUnlink({ matrix_room_id: roomId }); } catch (ex) { log.warn("Tried to unlink room but failed:", ex); + await this.main.notifyAdmins(`failed to unlink bridge on ${roomId}`); } } public async syncMembershipForRoom(roomId: string, channelId: string, teamId: string, client: WebClient): Promise { - const existingGhosts = await this.main.listGhostUsers(roomId); - // We assume that we have this const teamInfo = (await this.main.datastore.getTeam(teamId)); if (!teamInfo) { throw Error("Could not find team"); } - // Finally, sync membership for the channel. - const members = await client.conversations.members({channel: channelId}) as ConversationsMembersResponse; - // Ghosts will exist already: We joined them in the user sync. - const ghosts = await Promise.all(members.members.map(async(slackUserId) => this.main.ghostStore.get(slackUserId, teamInfo.domain, teamId))); - const joinedUsers = ghosts.filter((g) => !existingGhosts.includes(g.matrixUserId)); // Skip users that are joined. - const leftUsers = existingGhosts.map((userId) => ghosts.find((g) => g.matrixUserId === userId )).filter(g => !!g) as SlackGhost[]; - log.info(`Joining ${joinedUsers.length} ghosts to ${roomId}`); - log.info(`Leaving ${leftUsers.length} ghosts to ${roomId}`); + // create Set for both matrix membership state and slack membership state + // compare them to figure out who all needs to join the matrix room and leave the matrix room + // this obviously assumes we treat slack as the source of truth for membership + const existingMatrixUsersSet = new Set(); + const slackUsersSet = new Set(); + + const existingMatrixUsers = await this.main.listGhostAndMappedUsers(roomId); + for (const u of existingMatrixUsers) { + existingMatrixUsersSet.add(u); + } + + const slackUsers = await client.conversations.members({channel: channelId}) as ConversationsMembersResponse; + await Promise.all( + slackUsers.members.map(async(slackUserId) => { + const ghost = await this.main.ghostStore.get(slackUserId, teamInfo.domain, teamId); + slackUsersSet.add(ghost.matrixUserId); + }) + ); + + const joinedUsers: string[] = []; + slackUsersSet.forEach((u) => { + if (!existingMatrixUsersSet.has(u)) { + joinedUsers.push(u); + } + }); + const leftUsers = existingMatrixUsers.filter((userId) => !slackUsersSet.has(userId)); + + log.info(`Joining ${joinedUsers.length} ghosts to ${roomId}`,joinedUsers); + log.info(`Leaving ${leftUsers.length} ghosts to ${roomId}`,leftUsers); const queue = new PQueue({concurrency: JOIN_CONCURRENCY}); // Join users who aren't joined - queue.addAll(joinedUsers.map((ghost) => async () => { + queue.addAll(joinedUsers.map((userId) => async () => { try { - await this.main.membershipQueue.join(roomId, ghost.matrixUserId, { getId: () => ghost.matrixUserId }); + await this.main.membershipQueue.join(roomId, userId, { getId: () => userId }); } catch (ex) { - log.warn(`Failed to join ${ghost.matrixUserId} to ${roomId}`); + log.warn(`Failed to join ${userId} to ${roomId}`); } })).catch((ex) => log.error(`queue.addAll(joinedUsers) rejected with an error:`, ex)); // Leave users who are joined - queue.addAll(leftUsers.map((ghost) => async () => { + queue.addAll(leftUsers.map((userId) => async () => { try { - await this.main.membershipQueue.leave(roomId, ghost.matrixUserId, { getId: () => ghost.matrixUserId }); + await this.main.membershipQueue.leave(roomId, userId, { getId: () => userId }); } catch (ex) { - log.warn(`Failed to leave ${ghost.matrixUserId} from ${roomId}`); + log.warn(`Failed to leave ${userId} from ${roomId}`); } })).catch((ex) => log.error(`queue.addAll(leftUsers) rejected with an error:`, ex)); @@ -469,7 +524,8 @@ export class TeamSyncer { try { await client.chat.postEphemeral({ user: channelItem.creator, - text: `Hint: To bridge to Matrix, run the \`/invite @${user.name}\` command in this channel.`, + text: `Please invite \`@${user.name}\` to this channel (\`/invite @${user.name}\`) so that ` + + `the channel is also available on Matrix.`, channel: channelItem.id, }); } catch (error) { @@ -497,26 +553,62 @@ export class TeamSyncer { private async createRoomForChannel(teamId: string, creator: string, channel: ConversationsInfo, isPublic = true, inviteList: string[] = []): Promise { let intent: Intent; - let creatorUserId: string|undefined; - try { - creatorUserId = (await this.main.ghostStore.get(creator, undefined, teamId)).matrixUserId; + + let admins: string[] | undefined; + let mods: string[] | undefined; + let creatorFromConfig: string | undefined; + + for (const team in this.teamConfigs) { + if (team === "all" || team === teamId ) { + const teamConfig = this.teamConfigs[team]; + mods = teamConfig?.rooms?.moderators; + admins = teamConfig?.rooms?.administrators; + creatorFromConfig = teamConfig?.rooms?.creator; + break; + } + } + + // default behavior of bot user being admin on room + admins?.push(this.main.botUserId); + + // creator specified in config should be an admin + if (creatorFromConfig) { + admins?.push(creatorFromConfig); + } + + let creatorUserId = creatorFromConfig; + if (!creatorUserId) { + try { + creatorUserId = (await this.main.ghostStore.get(creator, undefined, teamId)).matrixUserId; + mods?.push(creatorUserId); // make Slack channel creator (user) a mod as well + } catch (ex) { + // Couldn't get the creator's mxid, will default to the bot below. + } + } + + if (creatorUserId) { intent = this.main.getIntent(creatorUserId); - } catch (ex) { - // Couldn't get the creator's mxid, using the bot. + } else { intent = this.main.botIntent; } + + // power levels + const plUsers = {}; + for (const mod of mods ?? []) { + plUsers[mod] = 50; + } + for (const admin of admins ?? []) { + plUsers[admin] = 100; + } + const aliasPrefix = this.getAliasPrefix(teamId); const alias = aliasPrefix ? `${aliasPrefix}${channel.name.toLowerCase()}` : undefined; let topic: undefined|string; if (channel.purpose) { topic = channel.purpose.value; } + log.debug("Creating new room for channel", channel.name, topic, alias); - const plUsers = {}; - plUsers[this.main.botUserId] = 100; - if (creatorUserId) { - plUsers[creatorUserId] = 100; - } inviteList = inviteList.filter((s) => s !== creatorUserId || s !== this.main.botUserId); inviteList.push(this.main.botUserId); const extraContent: Record[] = []; diff --git a/src/datastore/Models.ts b/src/datastore/Models.ts index 27152d3d..5565d09e 100644 --- a/src/datastore/Models.ts +++ b/src/datastore/Models.ts @@ -98,6 +98,7 @@ export interface Datastore extends ProvisioningStore { getMatrixUser(userId: string): Promise; getMatrixUsername(slackUserId: string): Promise; setMatrixUsername(slackUserId: string, matrixUsername: string): Promise; + getAllMatrixUsernames(): Promise; storeMatrixUser(user: MatrixUser): Promise; getAllUsersForTeam(teamId: string): Promise; diff --git a/src/datastore/NedbDatastore.ts b/src/datastore/NedbDatastore.ts index 30344db6..17d0ab79 100644 --- a/src/datastore/NedbDatastore.ts +++ b/src/datastore/NedbDatastore.ts @@ -172,6 +172,10 @@ export class NedbDatastore implements Datastore { throw Error("method not implemented"); } + public async getAllMatrixUsernames(): Promise { + throw Error("method not implemented"); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public async setMatrixUsername(slackUserId: string, matrixUsername: string): Promise { throw Error("method not implemented"); diff --git a/src/datastore/postgres/PgDatastore.ts b/src/datastore/postgres/PgDatastore.ts index 4e5802db..f7afff20 100644 --- a/src/datastore/postgres/PgDatastore.ts +++ b/src/datastore/postgres/PgDatastore.ts @@ -113,6 +113,13 @@ export class PgDatastore implements Datastore, ClientEncryptionStore, Provisioni ); } + public async getAllMatrixUsernames(): Promise { + const all = await this.postgresDb.manyOrNone( + "SELECT matrix_username FROM matrix_usernames" + ); + return all.map((dbEntry) => dbEntry ? dbEntry.matrix_username : null); + } + public async getAllUsersForTeam(teamId: string): Promise { const users = await this.postgresDb.manyOrNone("SELECT json FROM users WHERE json::json->>'team_id' = ${teamId}", { teamId, diff --git a/tests/utils/fakeDatastore.ts b/tests/utils/fakeDatastore.ts index 08077d42..2d524ca4 100644 --- a/tests/utils/fakeDatastore.ts +++ b/tests/utils/fakeDatastore.ts @@ -214,4 +214,8 @@ export class FakeDatastore implements Datastore { async setMatrixUsername(slackUserId: string, matrixUsername: string): Promise { throw new Error("Method not implemented."); } + + async getAllMatrixUsernames(): Promise { + throw new Error("Method not implemented."); + } }