diff --git a/src/BridgedRoom.ts b/src/BridgedRoom.ts index 30ddc126..18d9bbcb 100644 --- a/src/BridgedRoom.ts +++ b/src/BridgedRoom.ts @@ -23,12 +23,15 @@ 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"); -interface IBridgedRoomOpts { +export interface IBridgedRoomOpts { matrix_room_id: string; inbound_id: string; slack_channel_name?: string; @@ -115,29 +118,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 +143,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) { @@ -527,6 +516,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 = team.icon[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.png", + 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 +798,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 +852,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 +866,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 a5c949b1..f9694d6a 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"); @@ -413,6 +414,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) { @@ -901,12 +907,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/RoomUtils.ts b/src/RoomUtils.ts new file mode 100644 index 00000000..7770a897 --- /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/SlackEventHandler.ts b/src/SlackEventHandler.ts index fc6dd5ae..ce593832 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 8f9a2972..7946ee5c 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); } diff --git a/src/SlackResponses.ts b/src/SlackResponses.ts index 063d898a..0d5ba4ef 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; + } }; } diff --git a/src/rooms/DMRoom.ts b/src/rooms/DMRoom.ts new file mode 100644 index 00000000..f0c7743b --- /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 00000000..f06222ff --- /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); +} diff --git a/src/scripts/migrateToPostgres.ts b/src/scripts/migrateToPostgres.ts index 96f55bdb..0c375853 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})`); }));