Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Team sync changes #23

Merged
merged 30 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
caeb355
notify us admins in admin room about a deleted slack channel
ashfame Sep 29, 2023
7b831ab
notify us admins in admin room when bridge boots up
ashfame Sep 29, 2023
224fdb8
add bridged room id to notification message upon slack channel deletion
ashfame Oct 9, 2023
556b146
ensure rooms are created by super admin user, creator is only a mod a…
ashfame Oct 9, 2023
bdff14a
remove super_admin_user field in config and add rooms field under tea…
ashfame Oct 10, 2023
9f3f6c0
change hint message when new slack channel is created
ashfame Oct 10, 2023
c274bee
fix bug with membership sync logic
ashfame Oct 10, 2023
17505ea
handle channel_archive event
ashfame Oct 10, 2023
fe1627b
dedupe code from archive/deletion channel handlers
ashfame Oct 10, 2023
f77f12a
account for inconsistent event handling for channel_archive event
ashfame Oct 11, 2023
6570485
subscribe to member_left_channel event
ashfame Oct 11, 2023
e184ded
subscribe to channel_archive event as well
ashfame Oct 11, 2023
d902cdd
Revert "account for inconsistent event handling for channel_archive e…
ashfame Oct 11, 2023
d2fb55d
fix event name
ashfame Oct 11, 2023
e520141
notify us when a new bridged room is created upon a new slack channel…
ashfame Oct 12, 2023
e8035fb
disable updating ghost users upon each slack message when team sync i…
ashfame Oct 13, 2023
053c816
better var name
ashfame Oct 17, 2023
afb7a9d
move notifyAdmins func to Main
ashfame Oct 17, 2023
c087416
Update src/TeamSyncer.ts
ashfame Oct 17, 2023
bf0c565
Update src/TeamSyncer.ts
ashfame Oct 17, 2023
7bee73b
Update src/TeamSyncer.ts
ashfame Oct 17, 2023
c4f22ac
Update src/TeamSyncer.ts
ashfame Oct 17, 2023
0dfbb4f
improve performance of membership sync at boot
ashfame Oct 17, 2023
fb909d7
move power levels code a bit above
ashfame Oct 17, 2023
fcbe71b
improve code organisation in createRoomForChannel
ashfame Oct 17, 2023
12c63a0
notify us if unlinking fails, so that we can manually act
ashfame Oct 17, 2023
1db0e5a
define new method on fakeDatastore for tests to pass
ashfame Oct 17, 2023
e002c3b
log if fails to notify admins, makes integration test pass
ashfame Oct 17, 2023
88963fe
document team sync modifications in readme
ashfame Oct 18, 2023
28f6b85
move readme section
ashfame Oct 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 15 additions & 3 deletions src/BridgedRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,13 @@
id: `INTEG-${this.inboundId}`,
matrix_id: this.matrixRoomId,
remote: {
id: this.slackChannelId!,

Check warning on line 246 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
name: this.slackChannelName!,

Check warning on line 247 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
slack_team_id: this.slackTeamId!,

Check warning on line 248 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
slack_type: this.slackType!,

Check warning on line 249 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
slack_private: this.isPrivate,
webhook_uri: this.slackWebhookUri!,

Check warning on line 251 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
puppet_owner: this.puppetOwner!,

Check warning on line 252 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
},
remote_id: this.inboundId,
};
Expand All @@ -258,7 +258,7 @@
}

public async getClientForRequest(userId: string): Promise<{id: string, client: WebClient}|null> {
const puppet = await this.main.clientFactory.getClientForUserWithId(this.SlackTeamId!, userId);

Check warning on line 261 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
if (puppet) {
return puppet;
}
Expand All @@ -271,7 +271,7 @@
return null;
}

public async onMatrixReaction(message: any): Promise<void> {

Check warning on line 274 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Unexpected any. Specify a different type
const relatesTo = message.content["m.relates_to"];
const eventStore = this.main.datastore;
const event = await eventStore.getEventByMatrixId(message.room_id, relatesTo.event_id);
Expand Down Expand Up @@ -323,7 +323,7 @@
await this.main.datastore.upsertReaction({
roomId: message.room_id,
eventId: message.event_id,
slackChannelId: this.slackChannelId!,

Check warning on line 326 in src/BridgedRoom.ts

View workflow job for this annotation

GitHub Actions / call-lint / lint

Forbidden non-null assertion
slackMessageTs: event.slackTs,
// TODO We post reactions as the bot, not the user. Search for #fix_reactions_as_bot.
slackUserId: this.team!.user_id,
Expand Down Expand Up @@ -718,11 +718,23 @@
}
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)) {
Expand Down
27 changes: 27 additions & 0 deletions src/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,19 @@ export class Main {
return userIds.filter((i) => i.match(regexp));
}

public async listGhostAndMappedUsers(roomId: string): Promise<string[]> {
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<void> {
const userIds = await this.listGhostUsers(roomId);
log.info(`Draining ${userIds.length} ghosts from ${roomId}`);
Expand Down Expand Up @@ -1289,6 +1302,8 @@ export class Main {
}

log.info("Bridge initialised");
await this.notifyAdmins("Bridge initialised");

this.ready = true;
return port;
}
Expand Down Expand Up @@ -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);
}
}
}
}
7 changes: 5 additions & 2 deletions src/SlackEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
158 changes: 125 additions & 33 deletions src/TeamSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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);
ashfame marked this conversation as resolved.
Show resolved Hide resolved
}

public async onChannelArchived(teamId: string, channelId: string): Promise<void> {
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);
ashfame marked this conversation as resolved.
Show resolved Hide resolved
}

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<void> {
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<string>();
const slackUsersSet = new Set<string>();

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);
})
);
ashfame marked this conversation as resolved.
Show resolved Hide resolved

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));

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -497,26 +553,62 @@ export class TeamSyncer {
private async createRoomForChannel(teamId: string, creator: string, channel: ConversationsInfo,
isPublic = true, inviteList: string[] = []): Promise<string> {
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);
psrpinto marked this conversation as resolved.
Show resolved Hide resolved

// 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<string, unknown>[] = [];
Expand Down
1 change: 1 addition & 0 deletions src/datastore/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface Datastore extends ProvisioningStore {
getMatrixUser(userId: string): Promise<MatrixUser|null>;
getMatrixUsername(slackUserId: string): Promise<string|null>;
setMatrixUsername(slackUserId: string, matrixUsername: string): Promise<null>;
getAllMatrixUsernames(): Promise<string[]>;
storeMatrixUser(user: MatrixUser): Promise<null>;
getAllUsersForTeam(teamId: string): Promise<UserEntry[]>;

Expand Down
4 changes: 4 additions & 0 deletions src/datastore/NedbDatastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ export class NedbDatastore implements Datastore {
throw Error("method not implemented");
}

public async getAllMatrixUsernames(): Promise<string[]> {
throw Error("method not implemented");
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async setMatrixUsername(slackUserId: string, matrixUsername: string): Promise<null> {
throw Error("method not implemented");
Expand Down
Loading
Loading