From 0387e70876b0e1cdfe4f689951f5d1b0c6f3ea73 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 3 Dec 2019 14:54:06 +0000 Subject: [PATCH 1/4] Add support for typed rooms --- src/BridgedRoom.ts | 46 +++++++++++++++-------------------------- src/Main.ts | 9 ++++++-- src/rooms/DMRoom.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ src/rooms/Rooms.ts | 30 +++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 src/rooms/DMRoom.ts create mode 100644 src/rooms/Rooms.ts diff --git a/src/BridgedRoom.ts b/src/BridgedRoom.ts index 30ddc1265..c1362981b 100644 --- a/src/BridgedRoom.ts +++ b/src/BridgedRoom.ts @@ -28,7 +28,7 @@ import { RoomEntry, EventEntry, TeamEntry } from "./datastore/Models"; const log = Logging.get("BridgedRoom"); -interface IBridgedRoomOpts { +export interface IBridgedRoomOpts { matrix_room_id: string; inbound_id: string; slack_channel_name?: string; @@ -115,29 +115,15 @@ export class BridgedRoom { return this.slackType; } - public static fromEntry(main: Main, entry: RoomEntry, team?: TeamEntry, botClient?: WebClient) { - return new BridgedRoom(main, { - inbound_id: entry.remote_id, - matrix_room_id: entry.matrix_id, - slack_channel_id: entry.remote.id, - slack_channel_name: entry.remote.name, - slack_team_id: entry.remote.slack_team_id, - slack_webhook_uri: entry.remote.webhook_uri, - puppet_owner: entry.remote.puppet_owner, - is_private: entry.remote.slack_private, - slack_type: entry.remote.slack_type, - }, team, botClient); - } - - private matrixRoomId: string; - private inboundId: string; - private slackChannelName?: string; - private slackChannelId?: string; - private slackWebhookUri?: string; - private slackTeamId?: string; - private slackType?: string; - private isPrivate?: boolean; - private puppetOwner?: string; + protected matrixRoomId: string; + protected inboundId: string; + protected slackChannelName?: string; + protected slackChannelId?: string; + protected slackWebhookUri?: string; + protected slackTeamId?: string; + protected slackType?: string; + protected isPrivate?: boolean; + protected puppetOwner?: string; // last activity time in epoch seconds private slackATime?: number; @@ -154,7 +140,7 @@ export class BridgedRoom { */ private dirty: boolean; - constructor(private main: Main, opts: IBridgedRoomOpts, private team?: TeamEntry, private botClient?: WebClient) { + constructor(protected main: Main, opts: IBridgedRoomOpts, private team?: TeamEntry, private botClient?: WebClient) { this.MatrixRoomActive = true; if (!opts.inbound_id) { @@ -753,7 +739,7 @@ export class BridgedRoom { if (replyToEvent === null) { return null; } - const intent = await this.getIntentForRoom(roomID); + const intent = await this.getIntentForRoom(); return await intent.getClient().fetchRoomEvent(roomID, replyToEvent.eventId); } @@ -807,13 +793,13 @@ export class BridgedRoom { return parentEventId; // We have hit our depth limit, use this one. } - const intent = await this.getIntentForRoom(message.room_id); - const nextEvent = await intent.getClient().fetchRoomEvent(message.room_id, parentEventId); + const intent = await this.getIntentForRoom(); + const nextEvent = await intent.getClient().fetchRoomEvent(this.MatrixRoomId, parentEventId); return this.findParentReply(nextEvent, depth++); } - private async getIntentForRoom(roomID: string) { + protected async getIntentForRoom() { if (this.intent) { return this.intent; } @@ -821,7 +807,7 @@ export class BridgedRoom { if (!this.IsPrivate) { this.intent = this.main.botIntent; // Non-private channels should have the bot inside. } - const firstGhost = (await this.main.listGhostUsers(roomID))[0]; + const firstGhost = (await this.main.listGhostUsers(this.MatrixRoomId))[0]; this.intent = this.main.getIntent(firstGhost); return this.intent; } diff --git a/src/Main.ts b/src/Main.ts index c6bda2cc4..32076cb03 100644 --- a/src/Main.ts +++ b/src/Main.ts @@ -42,6 +42,7 @@ import PQueue from "p-queue"; import { UserAdminRoom } from "./rooms/UserAdminRoom"; import { TeamSyncer } from "./TeamSyncer"; import { AppService, AppServiceRegistration } from "matrix-appservice"; +import { fromEntry } from "./rooms/Rooms"; const log = Logging.get("Main"); @@ -903,12 +904,16 @@ export class Main { if (!slackClient && !entry.remote.webhook_uri) { // Do not warn if this is a webhook. log.warn(`${entry.remote.name} ${entry.remote.id} does not have a WebClient and will not be able to issue slack requests`); } - const room = BridgedRoom.fromEntry(this, entry, teamEntry, slackClient || undefined); + const room = fromEntry(this, entry, teamEntry, slackClient || undefined); await this.addBridgedRoom(room); room.MatrixRoomActive = activeRoom; if (!room.IsPrivate && activeRoom) { // Only public rooms can be tracked. - this.stateStorage.trackRoom(entry.matrix_id); + try { + await this.stateStorage.trackRoom(entry.matrix_id); + } catch (ex) { + this.stateStorage.untrackRoom(entry.matrix_id); + } } } diff --git a/src/rooms/DMRoom.ts b/src/rooms/DMRoom.ts new file mode 100644 index 000000000..f0c7743b7 --- /dev/null +++ b/src/rooms/DMRoom.ts @@ -0,0 +1,50 @@ +import { BridgedRoom, IBridgedRoomOpts } from "../BridgedRoom"; +import { Main } from "../Main"; +import { TeamEntry } from "../datastore/Models"; +import { WebClient } from "@slack/web-api"; +import { ISlackMessageEvent } from "../BaseSlackHandler"; +import { ConversationsMembersResponse } from "../SlackResponses"; +import { Logging } from "matrix-appservice-bridge"; + +const log = Logging.get("DMRoom"); + +/** + * The DM room class is used to implement custom logic for + * "im" and "mpim" rooms. + */ +export class DMRoom extends BridgedRoom { + constructor(main: Main, opts: IBridgedRoomOpts, team: TeamEntry, botClient: WebClient) { + super(main, opts, team, botClient); + } + + public async onSlackMessage(message: ISlackMessageEvent, content?: Buffer) { + await super.onSlackMessage(message, content); + + // Check if the recipient is joined to the room. + const cli = await this.main.clientFactory.getClientForUser(this.SlackTeamId!, this.puppetOwner!); + if (!cli) { + return; + } + + const expectedSlackMembers = (await cli.conversations.members({ channel: this.SlackChannelId! }) as ConversationsMembersResponse).members; + const expectedMatrixMembers = (await Promise.all(expectedSlackMembers.map( + (slackId) => this.main.datastore.getPuppetMatrixUserBySlackId(this.SlackTeamId!, slackId), + ))); + + const members = await this.main.listAllUsers(this.MatrixRoomId); + const intent = await this.getIntentForRoom(); + + try { + await Promise.all( + expectedMatrixMembers.filter((s) => s !== null && !members.includes(s)).map( + (member) => { + log.info(`Reinviting ${member} to the room`); + return intent.invite(this.MatrixRoomId, member); + }, + ), + ); + } catch (ex) { + log.warn("Failed to reinvite user to the room:", ex); + } + } +} diff --git a/src/rooms/Rooms.ts b/src/rooms/Rooms.ts new file mode 100644 index 000000000..f06222ff1 --- /dev/null +++ b/src/rooms/Rooms.ts @@ -0,0 +1,30 @@ +import { Main } from "../Main"; +import { RoomEntry, TeamEntry } from "../datastore/Models"; +import { WebClient } from "@slack/web-api"; +import { DMRoom } from "./DMRoom"; +import { BridgedRoom } from "../BridgedRoom"; + +export function fromEntry(main: Main, entry: RoomEntry, team?: TeamEntry, botClient?: WebClient) { + const slackType = entry.remote.slack_type; + const opts = { + inbound_id: entry.remote_id, + matrix_room_id: entry.matrix_id, + slack_channel_id: entry.remote.id, + slack_channel_name: entry.remote.name, + slack_team_id: entry.remote.slack_team_id, + slack_webhook_uri: entry.remote.webhook_uri, + puppet_owner: entry.remote.puppet_owner, + is_private: entry.remote.slack_private, + slack_type: entry.remote.slack_type, + }; + if (slackType === "im" || slackType === "mpim") { + if (!team) { + throw Error("'team' is undefined, but required for DM rooms"); + } + if (!botClient) { + throw Error("'botClient' is undefined, but required for DM rooms"); + } + return new DMRoom(main, opts, team, botClient); + } + return new BridgedRoom(main, opts, team, botClient); +} From e1f8c08a9d0219cc84a14c821efa40412e6b20b2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 3 Dec 2019 14:54:36 +0000 Subject: [PATCH 2/4] Tidyup --- src/SlackEventHandler.ts | 3 ++- src/SlackRTMHandler.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/SlackEventHandler.ts b/src/SlackEventHandler.ts index 3d3ea5cc5..a5433e2ee 100644 --- a/src/SlackEventHandler.ts +++ b/src/SlackEventHandler.ts @@ -59,7 +59,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_typing"]; + "team_domain_change", "channel_rename", "user_typing", + "channel_created", "channel_deleted", "user_change", "team_join"]; constructor(main: Main) { super(main); } diff --git a/src/SlackRTMHandler.ts b/src/SlackRTMHandler.ts index 8f9a29729..7946ee5c0 100644 --- a/src/SlackRTMHandler.ts +++ b/src/SlackRTMHandler.ts @@ -230,6 +230,7 @@ export class SlackRTMHandler extends SlackEventHandler { await this.main.datastore.upsertRoom(room); } else if (!room) { log.warn(`No room found for ${event.channel} and not sure how to create one`); + return; } return this.handleMessageEvent(event, puppet.teamId); } From 4e5e0478a92d10d9ae8c73e9f2a676a7a98e69cc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 4 Dec 2019 14:39:05 +0000 Subject: [PATCH 3/4] Experimental support for bridge info state events --- src/BridgedRoom.ts | 69 +++++++++++++++++++++++++++++++++++++--- src/Main.ts | 5 +++ src/SlackEventHandler.ts | 2 +- src/SlackResponses.ts | 9 ++++++ 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/BridgedRoom.ts b/src/BridgedRoom.ts index 30ddc1265..6e9ed5f39 100644 --- a/src/BridgedRoom.ts +++ b/src/BridgedRoom.ts @@ -23,8 +23,11 @@ import * as emoji from "node-emoji"; import { ISlackMessageEvent, ISlackEvent } from "./BaseSlackHandler"; import { WebClient } from "@slack/web-api"; import { ChatUpdateResponse, - ChatPostMessageResponse, ConversationsInfoResponse } from "./SlackResponses"; + ChatPostMessageResponse, ConversationsInfoResponse, TeamInfoResponse } from "./SlackResponses"; import { RoomEntry, EventEntry, TeamEntry } from "./datastore/Models"; +import { getBridgeStateKey, BridgeStateType, buildBridgeStateEvent } from "./RoomUtils"; +import { tenRetriesInAboutThirtyMinutes } from "@slack/web-api/dist/retry-policies"; +import e = require("express"); const log = Logging.get("BridgedRoom"); @@ -527,6 +530,62 @@ export class BridgedRoom { this.botClient = slackClient; } + public async syncBridgeState(force = false) { + if (!this.slackTeamId || !this.slackChannelId || this.isPrivate) { + return; // TODO: How to handle this? + } + const intent = await this.main.botIntent; + const key = getBridgeStateKey(this.slackTeamId, this.slackChannelId); + if (!force) { + // This throws if it can't find the event. + try { + await intent.getStateEvent( + this.MatrixRoomId, + BridgeStateType, + key, + ); + return; + } catch (ex) { + if (ex.message !== "Event not found.") { + throw ex; + } + } + } + + const { team } = await this.botClient!.team.info() as TeamInfoResponse; + let icon: string|undefined; + if (team.icon && !team.icon.image_default) { + const iconUrl = Object.keys(team.icon).filter((s) => s !== "icon_default").sort().reverse()[0]; + + const response = await rp({ + encoding: null, + resolveWithFullResponse: true, + uri: iconUrl, + }); + const content = response.body as Buffer; + + icon = await intent.getClient().uploadContent(content, { + name: "workspace-icon", + type: response.headers["Content-Type"], + rawResponse: false, + onlyContentUri: true, + }); + } + + // No state, build one. + const event = buildBridgeStateEvent({ + workspaceId: this.slackTeamId, + workspaceName: team.name, + workspaceUrl: `https://${team.domain}.slack.com`, + workspaceLogo: icon, + channelId: this.slackChannelId, + channelName: this.slackChannelName || undefined, + channelUrl: `https://app.slack.com/client/${this.slackTeamId}/${this.slackChannelId}`, + isActive: true, + }); + await intent.sendStateEvent(this.MatrixRoomId, event.type, key, event.content); + } + private setValue(key: string, value: T) { const sneakyThis = this as any; if (sneakyThis[key] === value) { @@ -753,7 +812,7 @@ export class BridgedRoom { if (replyToEvent === null) { return null; } - const intent = await this.getIntentForRoom(roomID); + const intent = await this.getIntentForRoom(); return await intent.getClient().fetchRoomEvent(roomID, replyToEvent.eventId); } @@ -807,13 +866,13 @@ export class BridgedRoom { return parentEventId; // We have hit our depth limit, use this one. } - const intent = await this.getIntentForRoom(message.room_id); + const intent = await this.getIntentForRoom(); const nextEvent = await intent.getClient().fetchRoomEvent(message.room_id, parentEventId); return this.findParentReply(nextEvent, depth++); } - private async getIntentForRoom(roomID: string) { + private async getIntentForRoom() { if (this.intent) { return this.intent; } @@ -821,7 +880,7 @@ export class BridgedRoom { if (!this.IsPrivate) { this.intent = this.main.botIntent; // Non-private channels should have the bot inside. } - const firstGhost = (await this.main.listGhostUsers(roomID))[0]; + const firstGhost = (await this.main.listGhostUsers(this.MatrixRoomId))[0]; this.intent = this.main.getIntent(firstGhost); return this.intent; } diff --git a/src/Main.ts b/src/Main.ts index a5c949b15..a6106fb43 100644 --- a/src/Main.ts +++ b/src/Main.ts @@ -413,6 +413,11 @@ export class Main { // doesn't currently have a client running. await this.slackRtm.startTeamClientIfNotStarted(room.SlackTeamId); } + try { + await room.syncBridgeState(); + } catch (ex) { + log.warn("Failed to sync bridge state:", ex); + } } public getInboundUrlForRoom(room: BridgedRoom) { diff --git a/src/SlackEventHandler.ts b/src/SlackEventHandler.ts index fc6dd5ae2..2fc1aeb16 100644 --- a/src/SlackEventHandler.ts +++ b/src/SlackEventHandler.ts @@ -59,7 +59,7 @@ 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_typing"]; + "team_domain_change", "channel_rename", "user_typing", "channel)created"]; constructor(main: Main) { super(main); } diff --git a/src/SlackResponses.ts b/src/SlackResponses.ts index 063d898a2..0d5ba4ef5 100644 --- a/src/SlackResponses.ts +++ b/src/SlackResponses.ts @@ -9,6 +9,15 @@ export interface TeamInfoResponse extends WebAPICallResult { id: string; name: string; domain: string; + icon: { + image_36?: string; + image_44?: string; + image_68?: string; + image_88?: string; + image_102?: string; + image_123?: string; + image_default: boolean; + } }; } From 8a17b2dc3983dbe4e442c1eddf25f31a13b965d4 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 4 Dec 2019 15:34:38 +0000 Subject: [PATCH 4/4] Add RoomUtils --- src/RoomUtils.ts | 43 ++++++++++++++++++++++++++++++++ src/scripts/migrateToPostgres.ts | 3 ++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/RoomUtils.ts diff --git a/src/RoomUtils.ts b/src/RoomUtils.ts new file mode 100644 index 000000000..7770a8976 --- /dev/null +++ b/src/RoomUtils.ts @@ -0,0 +1,43 @@ +export interface BuildBridgeStateEventOpts { + workspaceId: string; + workspaceName: string; + workspaceUrl: string; + workspaceLogo?: string; + channelId: string; + channelName?: string; + channelUrl: string; + creator?: string; + isActive: boolean; +} + +export function getBridgeStateKey(workspaceId: string, channelId: string) { + return `org.matrix.matrix-appservice-slack://slack/${workspaceId}/${channelId}`; +} + +export const BridgeStateType = "uk.half-shot.bridge"; + +export function buildBridgeStateEvent(opts: BuildBridgeStateEventOpts) { + // See https://github.com/matrix-org/matrix-doc/blob/hs/msc-bridge-inf/proposals/2346-bridge-info-state-event.md + return { + type: BridgeStateType, + content: { + ...(opts.creator ? {creator: opts.creator } : {}), + status: opts.isActive ? "active" : "inactive", + protocol: { + id: "slack", + displayname: "Slack", + }, + network: { + id: opts.workspaceId, + displayname: opts.workspaceName, + external_url: opts.workspaceUrl, + ...(opts.workspaceLogo ? {avatar: opts.workspaceLogo } : {}), + }, + channel: { + id: opts.channelId, + displayname: opts.channelName, + external_url: opts.channelUrl, + }, + }, + }; +} diff --git a/src/scripts/migrateToPostgres.ts b/src/scripts/migrateToPostgres.ts index 96f55bdb6..0c375853c 100644 --- a/src/scripts/migrateToPostgres.ts +++ b/src/scripts/migrateToPostgres.ts @@ -31,6 +31,7 @@ import { Datastore, TeamEntry } from "../datastore/Models"; import { WebClient } from "@slack/web-api"; import { TeamInfoResponse } from "../SlackResponses"; import { SlackClientFactory } from "../SlackClientFactory"; +import { fromEntry } from "../rooms/Rooms"; Logging.configure({ console: "info" }); const log = Logging.get("script"); @@ -144,7 +145,7 @@ export async function migrateFromNedb(nedb: NedbDatastore, targetDs: Datastore) if (!room.remote.slack_team_id && token) { room.remote.slack_team_id = teamTokenMap.get(token); } - await targetDs.upsertRoom(BridgedRoom.fromEntry(null as any, room)); + await targetDs.upsertRoom(fromEntry(null as any, room)); log.info(`Migrated room ${room.id} (${i + 1}/${allRooms.length})`); }));