diff --git a/src/AdminCommands.ts b/src/AdminCommands.ts index 6e2f2bbc..c3788225 100644 --- a/src/AdminCommands.ts +++ b/src/AdminCommands.ts @@ -38,6 +38,7 @@ export class AdminCommands { this.join, this.leave, this.stalerooms, + this.doOauth, this.help, ]; constructor(private main: Main) { @@ -288,6 +289,36 @@ export class AdminCommands { ); } + public get doOauth() { + return new AdminCommand( + "oauth userId puppet", + "generate an oauth url to bind your account with", + async ({userId, puppet, respond}) => { + if (!this.main.oauth2) { + respond("Oauth is not configured on this bridge"); + return; + } + const token = this.main.oauth2.getPreauthToken(userId as string); + const authUri = this.main.oauth2.makeAuthorizeURL( + token, + token, + puppet as boolean, + ); + respond(authUri); + }, + { + userId: { + type: "string", + description: "The userId to bind to the oauth token", + }, + puppet: { + type: "boolean", + description: "Does the user need puppeting permissions", + }, + }, + ); + } + public get help() { return new AdminCommand( "help [command]", diff --git a/src/BridgedRoom.ts b/src/BridgedRoom.ts index 1a289dcc..5550c08f 100644 --- a/src/BridgedRoom.ts +++ b/src/BridgedRoom.ts @@ -15,14 +15,15 @@ limitations under the License. */ import * as rp from "request-promise-native"; -import { Logging } from "matrix-appservice-bridge"; +import { Logging, Intent } from "matrix-appservice-bridge"; import { SlackGhost } from "./SlackGhost"; import { Main, METRIC_SENT_MESSAGES } from "./Main"; import { default as substitutions, getFallbackForMissingEmoji, ISlackToMatrixResult } from "./substitutions"; import * as emoji from "node-emoji"; import { ISlackMessageEvent, ISlackEvent } from "./BaseSlackHandler"; -import { WebClient, WebAPICallResult } from "@slack/web-api"; -import { TeamInfoResponse, AuthTestResponse, UsersInfoResponse, ChatUpdateResponse, ChatPostMessageResponse } from "./SlackResponses"; +import { WebClient } from "@slack/web-api"; +import { TeamInfoResponse, AuthTestResponse, UsersInfoResponse, ChatUpdateResponse, + ChatPostMessageResponse, ConversationsInfoResponse } from "./SlackResponses"; import { RoomEntry, EventEntry } from "./datastore/Models"; const log = Logging.get("BridgedRoom"); @@ -39,8 +40,11 @@ interface IBridgedRoomOpts { slack_team_id?: string; slack_user_id?: string; slack_bot_id?: string; + slack_type?: string; access_token?: string; access_scopes?: Set; + is_private?: boolean; + puppet_owner?: string; } interface ISlackChatMessagePayload extends ISlackToMatrixResult { @@ -51,7 +55,6 @@ interface ISlackChatMessagePayload extends ISlackToMatrixResult { } export class BridgedRoom { - public get isDirty() { return this.dirty; } @@ -140,6 +143,14 @@ export class BridgedRoom { return this.botClient; } + public get IsPrivate() { + return this.isPrivate; + } + + public get SlackType() { + return this.slackType; + } + public static fromEntry(main: Main, entry: RoomEntry, botClient?: WebClient) { const accessScopes: Set = new Set(entry.remote.access_scopes); return new BridgedRoom(main, { @@ -156,6 +167,9 @@ export class BridgedRoom { slack_user_id: entry.remote.slack_user_id, slack_user_token: entry.remote.slack_user_token, 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, }, botClient); } @@ -170,6 +184,9 @@ export class BridgedRoom { private slackTeamId?: string; private slackBotId?: string; private accessToken?: string; + private slackType?: string; + private isPrivate?: boolean; + private puppetOwner?: string; private slackUserId?: string; private accessScopes?: Set; @@ -177,6 +194,7 @@ export class BridgedRoom { // last activity time in epoch seconds private slackATime?: number; private matrixATime?: number; + private intent: Intent; /** * True if this instance has changed from the version last read/written to the RoomStore. @@ -206,10 +224,32 @@ export class BridgedRoom { this.slackBotId = opts.slack_bot_id; this.accessToken = opts.access_token; this.accessScopes = opts.access_scopes; + this.slackType = opts.slack_type || "channel"; + if (opts.is_private === undefined) { + opts.is_private = false; + } + this.isPrivate = opts.is_private; + this.puppetOwner = opts.puppet_owner; this.dirty = true; } + public updateUsingChannelInfo(channelInfo: ConversationsInfoResponse) { + const chan = channelInfo.channel; + this.setValue("isPrivate", chan.is_private); + if (chan.is_channel) { + this.setValue("slackType", "channel"); + } else if (chan.is_group) { + this.setValue("slackType", "group"); + } else if (chan.is_mpim) { + this.setValue("slackType", "mpim"); + } else if (chan.is_im) { + this.setValue("slackType", "im"); + } else { + this.setValue("slackType", "unknown"); + } + } + public getStatus() { if (!this.slackWebhookUri && !this.slackBotToken) { return "pending-params"; @@ -238,22 +278,25 @@ export class BridgedRoom { * Returns data to write to the RoomStore * As a side-effect will also clear the isDirty() flag */ - public toEntry() { + public toEntry(): RoomEntry { const entry = { id: `INTEG-${this.inboundId}`, matrix_id: this.matrixRoomId, remote: { access_scopes: this.accessScopes ? [...this.accessScopes] : [], - access_token: this.accessToken, - id: this.slackChannelId, - name: this.slackChannelName, - slack_bot_id: this.slackBotId, - slack_bot_token: this.slackBotToken, - slack_team_domain: this.slackTeamDomain, - slack_team_id: this.slackTeamId, - slack_user_id: this.slackUserId, - slack_user_token: this.slackUserToken, - webhook_uri: this.slackWebhookUri, + access_token: this.accessToken!, + id: this.slackChannelId!, + name: this.slackChannelName!, + slack_bot_id: this.slackBotId!, + slack_bot_token: this.slackBotToken!, + slack_team_domain: this.slackTeamDomain!, + slack_team_id: this.slackTeamId!, + slack_user_id: this.slackUserId!, + slack_user_token: this.slackUserToken!, + slack_type: this.slackType!, + slack_private: this.isPrivate!, + webhook_uri: this.slackWebhookUri!, + puppet_owner: this.puppetOwner!, }, remote_id: this.inboundId, }; @@ -368,13 +411,15 @@ export class BridgedRoom { } public async onMatrixMessage(message: any) { + const puppetedClient = await this.main.clientFactory.getClientForUser(this.SlackTeamId!, message.user_id); if (!this.slackWebhookUri && !this.botClient) { return; } - + const slackClient = puppetedClient || this.botClient; const user = this.main.getOrCreateMatrixUser(message.user_id); message = await this.stripMatrixReplyFallback(message); const matrixToSlackResult = await substitutions.matrixToSlack(message, this.main, this.SlackTeamId!); const body: ISlackChatMessagePayload = { ...matrixToSlackResult, + as_user: false, username: user.getDisplaynameForRoom(message.room_id) || matrixToSlackResult.username, }; @@ -396,9 +441,10 @@ export class BridgedRoom { user.bumpATime(); this.matrixATime = Date.now() / 1000; - if (!this.botClient) { + if (!slackClient) { const sendMessageParams = { body, + as_user: undefined, headers: {}, json: true, method: "POST", @@ -411,9 +457,12 @@ export class BridgedRoom { // Webhooks don't give us any ID, so we can't store this. return; } - const res = (await this.botClient.chat.postMessage({ + if (puppetedClient) { + body.as_user = true; + delete body.username; + } + const res = (await slackClient.chat.postMessage({ ...body, - as_user: false, channel: this.slackChannelId!, })) as ChatPostMessageResponse; @@ -440,12 +489,11 @@ export class BridgedRoom { parentStoredEvent._extras.slackThreadMessages.push(res.ts); await this.main.datastore.upsertEvent(parentStoredEvent); } - } - public async onSlackMessage(message: ISlackMessageEvent, teamId: string, content?: Buffer) { + public async onSlackMessage(message: ISlackMessageEvent, content?: Buffer) { try { - const ghost = await this.main.getGhostForSlackMessage(message, teamId); + const ghost = await this.main.getGhostForSlackMessage(message, this.slackTeamId!); await ghost.update(message, this); await ghost.cancelTyping(this.MatrixRoomId); // If they were typing, stop them from doing that. return await this.handleSlackMessage(message, ghost, content); @@ -508,7 +556,6 @@ export class BridgedRoom { const testRes = (await this.botClient.auth.test()) as AuthTestResponse; - log.debug("auth.test res:", testRes); if (!testRes.user_id) { return; } this.setValue("slackUserId", testRes.user_id); @@ -516,7 +563,7 @@ export class BridgedRoom { if (!usersRes.user || !usersRes.user.profile) { return; } this.setValue("slackBotId", usersRes.user.profile.bot_id); } - private setValue(key: string, value: any) { + private setValue(key: string, value: T) { const sneakyThis = this as any; if (sneakyThis[key] === value) { return; @@ -725,7 +772,7 @@ export class BridgedRoom { if (replyToEvent === null) { return null; } - const intent = this.main.botIntent; + const intent = await this.getIntentForRoom(roomID); return await intent.getClient().fetchRoomEvent(roomID, replyToEvent.eventId); } @@ -779,12 +826,24 @@ export class BridgedRoom { return parentEventId; // We have hit our depth limit, use this one. } - // Get the previous event - const intent = this.main.botIntent; + const intent = await this.getIntentForRoom(message.room_id); const nextEvent = await intent.getClient().fetchRoomEvent(message.room_id, parentEventId); return this.findParentReply(nextEvent, depth++); } + + private async getIntentForRoom(roomID: string) { + if (this.intent) { + return this.intent; + } + // Ensure we get the right user. + if (!this.IsPrivate) { + this.intent = this.main.botIntent; // Non-private channels should have the bot inside. + } + const firstGhost = (await this.main.listGhostUsers(roomID))[0]; + this.intent = this.main.getIntent(firstGhost); + return this.intent; + } } /** diff --git a/src/Main.ts b/src/Main.ts index d914675f..02ed3edd 100644 --- a/src/Main.ts +++ b/src/Main.ts @@ -30,15 +30,15 @@ import { AdminCommands } from "./AdminCommands"; import * as Provisioning from "./Provisioning"; import { INTERNAL_ID_LEN } from "./BaseSlackHandler"; import { SlackRTMHandler } from "./SlackRTMHandler"; -import { TeamInfoResponse, ConversationsInfoResponse } from "./SlackResponses"; +import { TeamInfoResponse, ConversationsInfoResponse, ConversationsOpenResponse, AuthTestResponse } from "./SlackResponses"; import { Datastore } from "./datastore/Models"; import { NedbDatastore } from "./datastore/NedbDatastore"; import { PgDatastore } from "./datastore/postgres/PgDatastore"; +import { SlackClientFactory } from "./SlackClientFactory"; import { Response } from "express"; const log = Logging.get("Main"); -const webLog = Logging.get(`slack-api`); const RECENT_EVENTID_SIZE = 20; export const METRIC_SENT_MESSAGES = "sent_messages"; @@ -69,6 +69,10 @@ export class Main { return this.bridge.getBot().userId(); } + public get clientFactory(): SlackClientFactory { + return this.clientfactory; + } + public readonly oauth2: OAuth2|null = null; public datastore!: Datastore; @@ -100,8 +104,7 @@ export class Main { private metrics: PrometheusMetrics; private adminCommands = new AdminCommands(this); - - private teamClients: Map = new Map(); + private clientfactory!: SlackClientFactory; constructor(public readonly config: IConfig, registration: any) { if (config.oauth2) { @@ -187,45 +190,6 @@ export class Main { return this.bridge.getIntent(userId); } - public async createOrGetTeamClient(teamId: string, token: string): Promise { - if (this.teamClients.has(teamId)) { - return this.teamClients.get(teamId)!; - } - return (await this.createTeamClient(token)).slackClient; - } - - public async createTeamClient(token: string) { - const opts = this.config.slack_client_opts; - const slackClient = new WebClient(token, { - ...opts, - logger: { - setLevel: () => {}, // We don't care about these. - setName: () => {}, - debug: (msg: any[]) => { - // non-ideal way to detect calls to slack. - webLog.debug.bind(webLog); - const match = /apiCall\('([\w\.]+)'\) start/.exec(msg[0]); - if (match && match[1]) { - this.incRemoteCallCounter(match[1]); - } - }, - warn: webLog.warn.bind(webLog), - info: webLog.info.bind(webLog), - error: webLog.error.bind(webLog), - }, - }); - const teamInfo = (await slackClient.team.info()) as TeamInfoResponse; - if (!teamInfo.ok) { - throw Error("Could not create team client: " + teamInfo.error); - } - this.teamClients.set(teamInfo.team.id, slackClient); - return { slackClient, team: teamInfo.team }; - } - - public getTeamClient(teamId: string): WebClient|undefined { - return this.teamClients.get(teamId); - } - public initialiseMetrics() { this.metrics = this.bridge.getPrometheusMetrics(); @@ -330,12 +294,11 @@ export class Main { const room = this.getRoomBySlackChannelId(message.channel || message.channel_id); - if (!room) { - log.error("Couldn't find channel in order to get team domain"); - return; + if (room && room.SlackTeamDomain) { + return room.SlackTeamDomain; } - const cli = this.getTeamClient(message.team_id); + const cli = this.clientFactory.getTeamClient(message.team_id); if (!cli) { throw Error("No client for team"); } @@ -362,8 +325,12 @@ export class Main { // team_domain is gone, so we have to actually get the domain from a friendly object. const teamDomain = (await this.getTeamDomainForMessage(message, teamId)).toLowerCase(); + return this.getGhostForSlack(message.user_id, teamDomain, teamId); + } + + public async getGhostForSlack(slackUserId: string, teamDomain: string, teamId: string): Promise { const userId = this.getUserId( - message.user_id.toUpperCase(), + slackUserId.toUpperCase(), teamDomain, ); @@ -383,9 +350,9 @@ export class Main { log.debug("Creating new ghost for", userId); ghost = new SlackGhost( this, + slackUserId.toUpperCase(), + teamId.toUpperCase(), userId, - undefined, - undefined, intent, ); await this.datastore.upsertUser(ghost); @@ -395,6 +362,16 @@ export class Main { return ghost; } + public async getExistingSlackGhost(userId: string): Promise { + const entry = await this.datastore.getUser(userId); + log.debug("Getting existing ghost for", userId); + if (!entry) { + return null; + } + const intent = this.bridge.getIntent(userId); + return SlackGhost.fromEntry(this, entry, intent); + } + public getOrCreateMatrixUser(id: string) { let u = this.matrixUsersById[id]; if (u) { @@ -428,7 +405,7 @@ export class Main { if (room.InboundId) { this.roomsByInboundId[room.InboundId] = room; } - if (!room.SlackTeamId && room.SlackBotToken) { + if ((!room.SlackTeamId || !room.SlackBotId) && room.SlackBotToken) { await room.refreshTeamInfo(); await room.refreshUserInfo(); } @@ -582,21 +559,17 @@ export class Main { if (ev.type === "m.room.member" && this.bridge.getBot().isRemoteUser(ev.state_key) - && ev.sender !== myUserId) { + && ev.sender !== myUserId + && ev.content.is_direct) { log.info(`${ev.state_key} got invite for ${ev.room_id} but we can't do DMs, warning room.`); - const intent = this.getIntent(ev.state_key); try { - await intent.join(ev.room_id); - await intent.sendEvent(ev.room_id, "m.room.message", { - body: "The slack bridge doesn't support private messaging, or inviting to rooms.", - msgtype: "m.notice", - }); - } catch (err) { - log.error("Couldn't send warning to user(s) about not supporting PMs", err); + await this.handleDmInvite(ev.state_key, ev.sender, ev.room_id); + endTimer({outcome: "success"}); + } catch (e) { + log.error("Failed to handle DM invite: ", e); + endTimer({outcome: "fail"}); } - await intent.leave(ev.room_id); - endTimer({outcome: "success"}); return; } @@ -679,6 +652,73 @@ export class Main { } } + public async handleDmInvite(recipient: string, sender: string, roomId: string) { + const intent = this.getIntent(recipient); + await intent.join(roomId); + if (!this.slackRtm) { + await intent.sendEvent(roomId, "m.room.message", { + body: "This slack bridge instance doesn't support private messaging.", + msgtype: "m.notice", + }); + await intent.leave(); + return; + } + + const slackGhost = await this.getExistingSlackGhost(recipient); + if (!slackGhost || !slackGhost.teamId) { + // TODO: Create users dynamically who have never spoken. + // https://github.com/matrix-org/matrix-appservice-slack/issues/211 + await intent.sendEvent(roomId, "m.room.message", { + body: "The user does not exist or has not used the bridge yet.", + msgtype: "m.notice", + }); + await intent.leave(roomId); + return; + } + const teamId = slackGhost.teamId; + const rtmClient = this.slackRtm!.getUserClient(teamId, sender); + const slackClient = await this.clientFactory.getClientForUser(teamId, sender); + if (!rtmClient || !slackClient) { + await intent.sendEvent(roomId, "m.room.message", { + body: "You have not enabled puppeting for this Slack workspace. You must do that to speak to members.", + msgtype: "m.notice", + }); + await intent.leave(roomId); + return; + } + const openResponse = (await slackClient.conversations.open({users: slackGhost.slackId, return_im: true})) as ConversationsOpenResponse; + if (openResponse.already_open) { + // Check to see if we have a room for this channel already. + const existing = this.roomsBySlackChannelId[openResponse.channel.id]; + if (existing) { + await this.datastore.deleteRoom(existing.InboundId); + await intent.sendEvent(roomId, "m.room.message", { + body: "You already have a conversation open with this person, leaving that room and reattaching here.", + msgtype: "m.notice", + }); + await intent.leave(existing.MatrixRoomId); + } + } + const puppetIdent = (await slackClient.auth.test()) as AuthTestResponse; + const teamInfo = (await slackClient.team.info()) as TeamInfoResponse; + // The convo may be open, but we do not have a channel for it. Create the channel. + const room = new BridgedRoom(this, { + inbound_id: openResponse.channel.id, + matrix_room_id: roomId, + slack_user_id: puppetIdent.user_id, + slack_team_id: puppetIdent.team_id, + slack_team_domain: teamInfo.team.domain, + slack_channel_id: openResponse.channel.id, + slack_channel_name: undefined, + puppet_owner: sender, + is_private: true, + }, slackClient); + room.updateUsingChannelInfo(openResponse); + await this.addBridgedRoom(room); + await this.datastore.upsertRoom(room); + await slackGhost.intent.joinRoom(roomId); + } + public async onMatrixAdminMessage(ev) { const cmd = ev.content.body; @@ -726,7 +766,9 @@ export class Main { log.info("Loading databases"); const dbEngine = this.config.db ? this.config.db.engine.toLowerCase() : "nedb"; if (dbEngine === "postgres") { - this.datastore = new PgDatastore(this.config.db!.connectionString); + const postgresDb = new PgDatastore(this.config.db!.connectionString); + await postgresDb.ensureSchema(); + this.datastore = postgresDb; } else if (dbEngine === "nedb") { await this.bridge.loadDatabases(); log.info("Loading teams.db"); @@ -753,6 +795,21 @@ export class Main { throw Error("Unknown engine for database. Please use 'postgres' or 'nedb"); } + this.clientfactory = new SlackClientFactory(this.datastore, this.config, (method: string) => { + this.incRemoteCallCounter(method); + }); + let puppetsWaiting: Promise = Promise.resolve(); + if (this.slackRtm) { + const puppetEntries = await this.datastore.getPuppetedUsers(); + puppetsWaiting = Promise.all(puppetEntries.map(async (entry) => { + try { + return this.slackRtm!.startUserClient(entry); + } catch (ex) { + log.warn(`Failed to start puppet client for ${entry.matrixId}:`, ex); + } + })); + } + if (this.slackHookHandler) { await this.slackHookHandler.startAndListen(this.config.slack_hook_port!, this.config.tls); } @@ -780,7 +837,9 @@ export class Main { let cli: WebClient|undefined; try { if (hasToken) { - cli = await this.createOrGetTeamClient(entry.remote.slack_team_id!, entry.remote.slack_bot_token!); + cli = await this.clientFactory.createOrGetTeamClient(entry.remote.slack_team_id!, entry.remote.slack_bot_token!); + } else if (entry.remote.puppet_owner) { + cli = await this.clientFactory.getClientForUser(entry.remote.slack_team_id!, entry.remote.puppet_owner); } } catch (ex) { log.error(`Failed to track room ${entry.matrix_id} ${entry.remote.name}:`, ex); @@ -790,7 +849,10 @@ export class Main { } const room = BridgedRoom.fromEntry(this, entry, cli); await this.addBridgedRoom(room); - this.stateStorage.trackRoom(entry.matrix_id); + if (!room.IsPrivate) { + // Only public rooms can be tracked. + this.stateStorage.trackRoom(entry.matrix_id); + } })); if (this.metrics) { @@ -799,6 +861,7 @@ export class Main { // startup this.metrics.refresh(); } + await puppetsWaiting; log.info("Bridge initialised."); } @@ -832,6 +895,7 @@ export class Main { room = new BridgedRoom(this, { inbound_id: inboundId, matrix_room_id: matrixRoomId, + is_private: false, }); isNew = true; this.roomsByMatrixRoomId[matrixRoomId] = room; @@ -865,7 +929,7 @@ export class Main { let cli: WebClient|undefined; if (opts.team_id || teamToken) { - cli = await this.createOrGetTeamClient(opts.team_id!, teamToken!); + cli = await this.clientFactory.createOrGetTeamClient(opts.team_id!, teamToken!); } if (cli && opts.slack_channel_id) { @@ -933,7 +997,7 @@ export class Main { return userLevel >= requiresLevel; } - public async setUserAccessToken(userId: string, teamId: string, slackId: string, accessToken: string) { + public async setUserAccessToken(userId: string, teamId: string, slackId: string, accessToken: string, puppeting: boolean) { let matrixUser = await this.datastore.getMatrixUser(userId); matrixUser = matrixUser ? matrixUser : new BridgeMatrixUser(userId); const accounts = matrixUser.get("accounts") || {}; @@ -943,6 +1007,16 @@ export class Main { }; matrixUser.set("accounts", accounts); await this.datastore.storeMatrixUser(matrixUser); + if (puppeting) { + // Store it here too for puppeting. + await this.datastore.setPuppetToken(teamId, slackId, userId, accessToken); + await this.slackRtm!.startUserClient({ + teamId, + slackId, + matrixId: userId, + token: accessToken, + }); + } log.info(`Set new access token for ${userId} (team: ${teamId})`); } @@ -956,12 +1030,12 @@ export class Main { } public async getNullGhostDisplayName(channel: string, userId: string): Promise { - const nullGhost = new SlackGhost(this); const room = this.getRoomBySlackChannelId(channel); + const nullGhost = new SlackGhost(this, userId, room!.SlackTeamId!, userId); if (!room || !room.SlackClient) { return userId; } - return (await nullGhost.getDisplayname(userId, room!.SlackClient!)) || userId; + return (await nullGhost.getDisplayname(room!.SlackClient!)) || userId; } private onHealth(_, res: Response) { diff --git a/src/OAuth2.ts b/src/OAuth2.ts index 26b1ef00..7088194c 100644 --- a/src/OAuth2.ts +++ b/src/OAuth2.ts @@ -37,6 +37,10 @@ const REQUIRED_SCOPES = [ "bot", ]; +const PUPPET_SCOPES = [ // See https://stackoverflow.com/a/28234443 + "client", +]; + export class OAuth2 { private readonly main: Main; private readonly userTokensWaiting: Map; @@ -54,9 +58,9 @@ export class OAuth2 { this.client = new WebClient(); } - public makeAuthorizeURL(room: string|BridgedRoom, state: string): string { + public makeAuthorizeURL(room: string|BridgedRoom, state: string, isPuppeting: boolean = false): string { const redirectUri = this.makeRedirectURL(room); - const scopes = Array.from(REQUIRED_SCOPES); + const scopes = isPuppeting ? REQUIRED_SCOPES : PUPPET_SCOPES; const qs = querystring.stringify({ client_id: this.clientId, diff --git a/src/Provisioning.ts b/src/Provisioning.ts index 32611f2e..7181d438 100644 --- a/src/Provisioning.ts +++ b/src/Provisioning.ts @@ -19,7 +19,7 @@ import * as rp from "request-promise-native"; import { Request, Response} from "express"; import { Main } from "./Main"; import { HTTP_CODES } from "./BaseSlackHandler"; -import { ConversationsListResponse } from "./SlackResponses"; +import { ConversationsListResponse, AuthTestResponse } from "./SlackResponses"; const log = Logging.get("Provisioning"); @@ -91,9 +91,9 @@ commands.getbotid = new Command({ }, }); -commands.authlog = new Command({ - params: ["user_id"], - func(main, req, res, userId) { +commands.authurl = new Command({ + params: ["user_id", "puppeting"], + func(main, req, res, userId, puppeting) { if (!main.oauth2) { res.status(HTTP_CODES.CLIENT_ERROR).json({ error: "OAuth2 not configured on this bridge", @@ -104,6 +104,7 @@ commands.authlog = new Command({ const authUri = main.oauth2.makeAuthorizeURL( token, token, + puppeting === "true", ); res.json({ auth_uri: authUri, @@ -147,7 +148,7 @@ commands.channels = new Command({ if (team === null) { throw new Error("No team token for this team_id"); } - const cli = await main.createOrGetTeamClient(teamId, team.bot_token); + const cli = await main.clientFactory.createOrGetTeamClient(teamId, team.bot_token); const response = (await cli.conversations.list({ exclude_archived: true, limit: 100, // TODO: Pagination @@ -195,6 +196,44 @@ commands.teams = new Command({ }, }); +commands.accounts = new Command({ + params: ["user_id"], + async func(main, _, res, userId) { + log.debug(`${userId} requested their puppeted accounts`); + const accts = await main.datastore.getPuppetsByMatrixId(userId); + // tslint:disable-next-line: no-any + const accounts = await Promise.all(accts.map(async (acct: any) => { + delete acct.token; + const client = await main.clientFactory.getClientForUser(acct.teamId, acct.matrixId); + if (client) { + try { + const identity = (await client.auth.test()) as AuthTestResponse; + acct.identity = { + team: identity.team, + name: identity.user, + }; + } catch (ex) { + return acct; + } + } + return acct; + })); + res.json({ accounts }); + }, +}); + +commands.removeaccount = new Command({ + params: ["user_id", "team_id"], + async func(main, _, res, userId, teamId) { + log.debug(`${userId} is removing their account on ${teamId}`); + const client = await main.clientFactory.getClientForUser(teamId, userId); + if (client) { + await client.auth.revoke(); + } + await main.datastore.removePuppetTokenByMatrixId(teamId, userId); + res.json({ }); + }, +}); commands.getlink = new Command({ params: ["matrix_room_id", "user_id"], async func(main, req, res, matrixRoomId, userId) { diff --git a/src/SlackClientFactory.ts b/src/SlackClientFactory.ts new file mode 100644 index 00000000..70698c94 --- /dev/null +++ b/src/SlackClientFactory.ts @@ -0,0 +1,80 @@ +import { Datastore } from "./datastore/Models"; +import { WebClient } from "@slack/web-api"; +import { IConfig } from "./IConfig"; +import { Logging } from "matrix-appservice-bridge"; +import { TeamInfoResponse } from "./SlackResponses"; +import { ISlackMessageEvent } from "./BaseSlackHandler"; + +const webLog = Logging.get("slack-api"); +const log = Logging.get("SlackClientFactory"); + +/** + * This class holds clients for slack teams and individuals users + * who are puppeting their accounts. + */ +export class SlackClientFactory { + private teamClients: Map = new Map(); + private puppets: Map = new Map(); + constructor(private datastore: Datastore, private config: IConfig, private onRemoteCall: (method: string) => void) { + + } + + public async createOrGetTeamClient(teamId: string, token: string): Promise { + if (this.teamClients.has(teamId)) { + return this.teamClients.get(teamId)!; + } + return (await this.createTeamClient(token)).slackClient; + } + + public async createTeamClient(token: string) { + const opts = this.config.slack_client_opts; + const slackClient = new WebClient(token, { + ...opts, + logger: { + setLevel: () => {}, // We don't care about these. + setName: () => {}, + debug: (msg: any[]) => { + // non-ideal way to detect calls to slack. + webLog.debug.bind(webLog); + const match = /apiCall\('([\w\.]+)'\) start/.exec(msg[0]); + if (match && match[1]) { + this.onRemoteCall(match[1]); + } + }, + warn: webLog.warn.bind(webLog), + info: webLog.info.bind(webLog), + error: webLog.error.bind(webLog), + }, + }); + const teamInfo = (await slackClient.team.info()) as TeamInfoResponse; + if (!teamInfo.ok) { + throw Error("Could not create team client: " + teamInfo.error); + } + this.teamClients.set(teamInfo.team.id, slackClient); + return { slackClient, team: teamInfo.team }; + } + + public getTeamClient(teamId: string): WebClient|undefined { + return this.teamClients.get(teamId); + } + + public async getClientForUser(teamId: string, matrixUser: string): Promise { + const key = `${teamId}:${matrixUser}`; + if (this.puppets.has(key)) { + return this.puppets.get(key); + } + const token = await this.datastore.getPuppetTokenByMatrixId(teamId, matrixUser); + if (!token) { + return; + } + const client = new WebClient(token); + try { + await client.auth.test(); + } catch (ex) { + log.warn("Failed to get puppeted client for user:", ex); + return; + } + this.puppets.set(key, client); + return client; + } +} diff --git a/src/SlackEventHandler.ts b/src/SlackEventHandler.ts index 2f8d69a7..6200f071 100644 --- a/src/SlackEventHandler.ts +++ b/src/SlackEventHandler.ts @@ -18,6 +18,9 @@ import { BaseSlackHandler, ISlackEvent, ISlackMessageEvent, ISlackMessage } from import { BridgedRoom } from "./BridgedRoom"; import { Main } from "./Main"; import { Logging } from "matrix-appservice-bridge"; +import { ConversationsInfoResponse } from "./SlackResponses"; +import { WebClient } from "@slack/web-api"; +import { PuppetEntry } from "./datastore/Models"; const log = Logging.get("SlackEventHandler"); @@ -138,8 +141,7 @@ export class SlackEventHandler extends BaseSlackHandler { const room = this.main.getRoomBySlackChannelId(event.channel) as BridgedRoom; if (!room) { throw new Error("unknown_channel"); } - if (event.subtype === "bot_message" && - (!room.SlackBotId || event.bot_id === room.SlackBotId)) { + if (event.bot_id && (!room.SlackBotId || event.bot_id === room.SlackBotId)) { return; } @@ -168,7 +170,7 @@ export class SlackEventHandler extends BaseSlackHandler { // (because we don't have a master token), but it has text, // just send the message as text. log.warn("no slack token for " + room.SlackTeamDomain || room.SlackChannelId); - return room.onSlackMessage(msg, teamId); + return room.onSlackMessage(msg); } // Handle events with attachments like bot messages. @@ -176,7 +178,7 @@ export class SlackEventHandler extends BaseSlackHandler { for (const attachment of msg.attachments) { msg.text = attachment.fallback; msg.text = await this.doChannelUserReplacements(msg, msg.text!, room.SlackClient); - return await room.onSlackMessage(msg, teamId); + return await room.onSlackMessage(msg); } if (msg.text === "") { return; @@ -221,7 +223,7 @@ export class SlackEventHandler extends BaseSlackHandler { // (because we don't have a master token), but it has text, // just send the message as text. log.warn("no slack token for " + room.SlackTeamDomain || room.SlackChannelId); - return room.onSlackMessage(event, teamId); + return room.onSlackMessage(event); } let content: Buffer|undefined; @@ -241,7 +243,7 @@ export class SlackEventHandler extends BaseSlackHandler { } msg.text = await this.doChannelUserReplacements(msg, msg.text!, room.SlackClient); - return room.onSlackMessage(msg, teamId, content); + return room.onSlackMessage(msg, content); } private async handleReaction(event: ISlackEventReaction, teamId: string) { diff --git a/src/SlackGhost.ts b/src/SlackGhost.ts index a6a840f3..6e63909d 100644 --- a/src/SlackGhost.ts +++ b/src/SlackGhost.ts @@ -22,18 +22,13 @@ import { BridgedRoom } from "./BridgedRoom"; import { ISlackUser } from "./BaseSlackHandler"; import { WebClient } from "@slack/web-api"; import { BotsInfoResponse, UsersInfoResponse } from "./SlackResponses"; +import { UserEntry } from "./datastore/Models"; const log = Logging.get("SlackGhost"); // How long in milliseconds to cache user info lookups. const USER_CACHE_TIMEOUT = 10 * 60 * 1000; // 10 minutes -interface ISlackGhostEntry { - id?: string; - display_name?: string; - avatar_url?: string; -} - interface IMatrixReplyEvent { sender: string; event_id: string; @@ -49,13 +44,15 @@ export class SlackGhost { return this.atime; } - public static fromEntry(main: Main, entry: ISlackGhostEntry, intent: Intent) { + public static fromEntry(main: Main, entry: UserEntry, intent: Intent) { return new SlackGhost( main, + entry.slack_id, + entry.team_id, entry.id, + intent, entry.display_name, entry.avatar_url, - intent, ); } private atime?: number; @@ -64,17 +61,21 @@ export class SlackGhost { private userInfoLoading?: Promise; constructor( private main: Main, - private userId?: string, + public readonly slackId: string, + public readonly teamId: string, + public readonly userId: string, + public readonly intent?: Intent, private displayName?: string, - private avatarUrl?: string, - public readonly intent?: Intent) { + private avatarUrl?: string) { } - public toEntry(): ISlackGhostEntry { + public toEntry(): UserEntry { return { - avatar_url: this.avatarUrl, - display_name: this.displayName, - id: this.userId, + avatar_url: this.avatarUrl!, + display_name: this.displayName!, + id: this.userId!, + slack_id: this.slackId, + team_id: this.teamId, }; } @@ -90,8 +91,8 @@ export class SlackGhost { ]); } - public async getDisplayname(slackUserId: string, client: WebClient) { - const user = await this.lookupUserInfo(slackUserId, client); + public async getDisplayname(client: WebClient) { + const user = await this.lookupUserInfo(client); if (user && user.profile) { return user.profile.display_name || user.profile.real_name; } @@ -108,7 +109,7 @@ export class SlackGhost { if (message.bot_id) { displayName = await this.getBotName(message.bot_id, room.SlackClient); } else if (message.user_id) { - displayName = await this.getDisplayname(message.user_id, room.SlackClient); + displayName = await this.getDisplayname(room.SlackClient); } if (!displayName || this.displayName === displayName) { @@ -120,8 +121,8 @@ export class SlackGhost { return this.main.datastore.upsertUser(this); } - public async lookupAvatarUrl(slackUserId: string, client: WebClient) { - const user = await this.lookupUserInfo(slackUserId, client); + public async lookupAvatarUrl(client: WebClient) { + const user = await this.lookupUserInfo(client); if (!user || !user.profile) { return; } const profile = user.profile; @@ -152,9 +153,9 @@ export class SlackGhost { icons.image_192 || icons.image_72 || icons.image_48; } - public async lookupUserInfo(slackUserId: string, client: WebClient) { + public async lookupUserInfo(client: WebClient) { if (this.userInfoCache) { - log.debug("Using cached userInfo for", slackUserId); + log.debug("Using cached userInfo for", this.slackId); return this.userInfoCache; } if (this.userInfoLoading) { @@ -164,9 +165,9 @@ export class SlackGhost { } return; } - log.debug("Using fresh userInfo for", slackUserId); + log.debug("Using fresh userInfo for", this.slackId); - this.userInfoLoading = client.users.info({user: slackUserId}) as Promise; + this.userInfoLoading = client.users.info({user: this.slackId}) as Promise; const response = await this.userInfoLoading!; if (!response.user || !response.user.profile) { log.error("Failed to get user profile", response); @@ -186,7 +187,7 @@ export class SlackGhost { if (message.bot_id) { avatarUrl = await this.getBotAvatarUrl(message.bot_id, room.SlackClient); } else if (message.user_id) { - avatarUrl = await this.lookupAvatarUrl(message.user_id, room.SlackClient); + avatarUrl = await this.lookupAvatarUrl(room.SlackClient); } else { return; } diff --git a/src/SlackHookHandler.ts b/src/SlackHookHandler.ts index ee2b4a78..c4a289ae 100644 --- a/src/SlackHookHandler.ts +++ b/src/SlackHookHandler.ts @@ -263,7 +263,7 @@ export class SlackHookHandler extends BaseSlackHandler { if (params.text) { // Converting params to an object here, as we assume that params is the right shape. - return room.onSlackMessage(params as unknown as ISlackMessageEvent, teamId); + return room.onSlackMessage(params as unknown as ISlackMessageEvent); } return; } @@ -284,7 +284,7 @@ export class SlackHookHandler extends BaseSlackHandler { // them by now PRESERVE_KEYS.forEach((k) => lookupRes.message[k] = params[k]); lookupRes.message.text = await this.doChannelUserReplacements(lookupRes.message, text, room.SlackClient); - return room.onSlackMessage(lookupRes.message, teamId, lookupRes.content); + return room.onSlackMessage(lookupRes.message, lookupRes.content); } private async handleAuthorize(roomOrToken: BridgedRoom|string, params: {[key: string]: string|string[]}) { @@ -325,18 +325,23 @@ export class SlackHookHandler extends BaseSlackHandler { room.updateAccessToken(response.access_token, new Set(access_scopes)); await this.main.datastore.upsertRoom(room); } else if (user) { // New event api + // We always get a user access token, but if we set certain + // fancy scopes we might not get a bot one. await this.main.setUserAccessToken( user, response.team_id, response.user_id, response.access_token, + response.bot === undefined, ); - this.main.datastore.upsertTeam( - response.team_id, - response.team_name, - response.bot!.bot_user_id, - response.bot!.bot_access_token, - ); + if (response.bot) { + this.main.datastore.upsertTeam( + response.team_id, + response.team_name, + response.bot!.bot_user_id, + response.bot!.bot_access_token, + ); + } } } catch (err) { log.error("Error during handling of an oauth token:", err); diff --git a/src/SlackRTMHandler.ts b/src/SlackRTMHandler.ts index 2da4f88d..e50fc845 100644 --- a/src/SlackRTMHandler.ts +++ b/src/SlackRTMHandler.ts @@ -2,6 +2,11 @@ import { RTMClient, LogLevel } from "@slack/rtm-api"; import { Main, ISlackTeam } from "./Main"; import { SlackEventHandler } from "./SlackEventHandler"; import { Logging } from "matrix-appservice-bridge"; +import { PuppetEntry } from "./datastore/Models"; +import { ConversationsInfoResponse, ConversationsMembersResponse } from "./SlackResponses"; +import { ISlackMessageEvent } from "./BaseSlackHandler"; +import { WebClient } from "@slack/web-api"; +import { BridgedRoom } from "./BridgedRoom"; const log = Logging.get("SlackRTMHandler"); @@ -11,51 +16,73 @@ const LOG_TEAM_LEN = 12; * It reuses the SlackEventHandler to handle events. */ export class SlackRTMHandler extends SlackEventHandler { - private rtmClients: Map>; // team -> client + private rtmTeamClients: Map>; // team -> client + private rtmUserClients: Map; // team:mxid -> client constructor(main: Main) { super(main); - this.rtmClients = new Map(); + this.rtmTeamClients = new Map(); + this.rtmUserClients = new Map(); + } + + public async getUserClient(teamId: string, matrixId: string): Promise { + const key = `${teamId}:${matrixId}`; + return this.rtmUserClients.get(key); + } + + public async startUserClient(puppetEntry: PuppetEntry) { + const key = `${puppetEntry.teamId}:${puppetEntry.matrixId}`; + if (this.rtmUserClients.has(key)) { + log.debug(`${key} is already connected`); + return; + } + log.debug(`Starting RTM client for user ${key}`); + const rtm = this.createRtmClient(puppetEntry.token, puppetEntry.matrixId); + const slackClient = await this.main.clientFactory.getClientForUser(puppetEntry.teamId, puppetEntry.matrixId); + if (!slackClient) { + return; // We need to be able to determine what a channel looks like. + } + let teamInfo: ISlackTeam; + rtm.on("message", async (e) => { + const chanInfo = (await slackClient!.conversations.info({channel: e.channel})) as ConversationsInfoResponse; + // is_private is unreliably set. + chanInfo.channel.is_private = chanInfo.channel.is_im || chanInfo.channel.is_group; + if (chanInfo.channel.is_channel || !chanInfo.channel.is_private) { + // Never forward messages on from the users workspace if it's public + } + // Sneaky hack to set the domain on messages. + e.team_id = teamInfo.id; + e.team_domain = teamInfo.domain; + e.user_id = e.user; + await this.handleUserMessage(chanInfo, e, slackClient, puppetEntry); + }); + this.rtmUserClients.set(key, rtm); + const { team } = await rtm.start(); + teamInfo = team as ISlackTeam; + + log.debug(`Started RTM client for user ${key}`, team); } public teamIsUsingRtm(teamId: string): boolean { - return this.rtmClients.has(teamId); + return this.rtmTeamClients.has(teamId.toUpperCase()); } public async startTeamClientIfNotStarted(expectedTeam: string, botToken: string) { - if (this.rtmClients.has(expectedTeam)) { + if (this.rtmTeamClients.has(expectedTeam)) { log.debug(`${expectedTeam} is already connected`); try { - await this.rtmClients.get(expectedTeam); + await this.rtmTeamClients.get(expectedTeam); return; } catch (ex) { log.warn("Failed to create RTM client"); } } const promise = this.startTeamClient(expectedTeam, botToken); - this.rtmClients.set(expectedTeam.toUpperCase(), promise); + this.rtmTeamClients.set(expectedTeam.toUpperCase(), promise); await promise; } private async startTeamClient(expectedTeam: string, botToken: string) { - const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"]; - const connLog = Logging.get(`RTM-${expectedTeam.substr(0, LOG_TEAM_LEN)}`); - const logLevel = LOG_LEVELS.indexOf(this.main.config.rtm!.log_level || "silent"); - const rtm = new RTMClient(botToken, { - logLevel: LogLevel.DEBUG, // We will filter this ourselves. - logger: { - setLevel: () => {}, - setName: () => {}, // We handle both of these ourselves. - debug: logLevel <= 0 ? connLog.debug.bind(connLog) : () => {}, - warn: logLevel <= 1 ? connLog.warn.bind(connLog) : () => {}, - info: logLevel <= 2 ? connLog.info.bind(connLog) : () => {}, - error: logLevel <= 3 ? connLog.error.bind(connLog) : () => {}, - }, - }); - - rtm.on("error", (error) => { - // We must handle this lest the process be killed. - connLog.error("Encountered 'error' event:", error); - }); + const rtm = this.createRtmClient(botToken, expectedTeam); // For each event that SlackEventHandler supports, register // a listener. @@ -83,4 +110,69 @@ export class SlackRTMHandler extends SlackEventHandler { } return rtm; } + + private createRtmClient(token: string, logLabel: string): RTMClient { + const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"]; + const connLog = Logging.get(`RTM-${logLabel.substr(0, LOG_TEAM_LEN)}`); + const logLevel = LOG_LEVELS.indexOf(this.main.config.rtm!.log_level || "silent"); + const rtm = new RTMClient(token, { + logLevel: LogLevel.DEBUG, // We will filter this ourselves. + logger: { + setLevel: () => {}, + setName: () => {}, // We handle both of these ourselves. + debug: logLevel <= 0 ? connLog.debug.bind(connLog) : () => {}, + warn: logLevel <= 1 ? connLog.warn.bind(connLog) : () => {}, + info: logLevel <= 2 ? connLog.info.bind(connLog) : () => {}, + error: logLevel <= 3 ? connLog.error.bind(connLog) : () => {}, + }, + }); + + rtm.on("error", (error) => { + // We must handle this lest the process be killed. + connLog.error("Encountered 'error' event:", error); + }); + return rtm; + } + + private async handleUserMessage(chanInfo: ConversationsInfoResponse, event: ISlackMessageEvent, slackClient: WebClient, puppet: PuppetEntry) { + log.debug("Received slack user event:", puppet.matrixId, event); + const channelMembersRes = (await slackClient.conversations.members({ channel: chanInfo.channel.id })) as ConversationsMembersResponse; + const ghosts = await Promise.all(channelMembersRes.members.map( + // tslint:disable-next-line: no-any + (id) => this.main.getGhostForSlack(id, (event as any).team_domain, puppet.teamId)), + ); + const ghost = await this.main.getGhostForSlackMessage(event, puppet.teamId); + let room = this.main.getRoomBySlackChannelId(event.channel) as BridgedRoom; + if (!room && chanInfo.channel.is_im) { + log.info(`Creating new DM room for ${event.channel}`); + // Create a new DM room. + const { room_id } = await ghost.intent.createRoom({ + createAsClient: true, + options: { + invite: [puppet.matrixId].concat(ghosts.map((g) => g.userId!)), + preset: "private_chat", + is_direct: true, + }, + }); + room = new BridgedRoom(this.main, { + inbound_id: chanInfo.channel.id, + matrix_room_id: room_id, + slack_user_id: puppet.slackId, + slack_team_id: puppet.teamId, + // We hacked this in above. + // tslint:disable-next-line: no-any + slack_team_domain: (event as any).team_domain, + slack_channel_id: chanInfo.channel.id, + slack_channel_name: chanInfo.channel.name, + puppet_owner: puppet.matrixId, + is_private: chanInfo.channel.is_private, + }, slackClient); + room.updateUsingChannelInfo(chanInfo); + await this.main.addBridgedRoom(room); + 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 this.handleMessageEvent(event, puppet.teamId); + } } diff --git a/src/SlackResponses.ts b/src/SlackResponses.ts index 5caea852..9b17bf74 100644 --- a/src/SlackResponses.ts +++ b/src/SlackResponses.ts @@ -19,9 +19,37 @@ export interface ConversationsInfoResponse extends WebAPICallResult { channel: { id: string; name: string; + is_im?: boolean; + is_mpim?: boolean; + is_group?: boolean; + is_channel?: boolean; + is_private?: boolean; }; } +/** + * Taken from https://api.slack.com/methods/conversations.info + */ +export interface ConversationsOpenResponse extends ConversationsInfoResponse { + no_op: boolean; + already_open: boolean; + channel: { + id: string; + name: string; + is_im: boolean; + is_group: boolean; + is_channel: boolean; + is_private: boolean; + }; +} + +/** + * Taken from https://api.slack.com/methods/conversations.members + */ +export interface ConversationsMembersResponse extends WebAPICallResult { + members: string[]; +} + /** * Taken from https://api.slack.com/methods/auth.test */ diff --git a/src/datastore/Models.ts b/src/datastore/Models.ts index ffbb6c1f..59e7735e 100644 --- a/src/datastore/Models.ts +++ b/src/datastore/Models.ts @@ -22,26 +22,29 @@ export interface RoomEntry { matrix_id: string; remote_id: string; remote: { - slack_team_id?: string; + slack_bot_id: string; slack_bot_token?: string; + slack_team_id?: string; + slack_team_domain: string; + slack_user_id: string; + slack_user_token: string; + slack_type?: string; access_scopes: string[]; access_token: string; - slack_bot_id: string; id: string; name: string; - slack_team_domain: string; - slack_user_id: string; - slack_user_token: string; webhook_uri: string; + slack_private?: boolean; + puppet_owner?: string; }; } export interface UserEntry { id: string; - matrix_id: string; display_name: string; - get: (key: string) => string; - set: (key: string, value: string) => void; + avatar_url: string; + slack_id: string; + team_id: string; } export interface EventEntry { @@ -63,6 +66,13 @@ export interface TeamEntry { user_id: string; } +export interface PuppetEntry { + matrixId: string; + teamId: string; + slackId: string; + token: string; +} + export interface Datastore { upsertUser(user: SlackGhost): Promise; getUser(id: string): Promise; @@ -80,4 +90,11 @@ export interface Datastore { upsertTeam(teamId: string, botToken: string, teamName: string, botId: string); getTeam(teamId: string): Promise; + + setPuppetToken(teamId: string, slackUser: string, matrixId: string, token: string): Promise; + getPuppetTokenBySlackId(teamId: string, slackId: string): Promise; + getPuppetTokenByMatrixId(teamId: string, matrixId: string): Promise; + removePuppetTokenByMatrixId(teamId: string, matrixId: string): Promise; + getPuppetsByMatrixId(userId: string): Promise; + getPuppetedUsers(): Promise; } diff --git a/src/datastore/NedbDatastore.ts b/src/datastore/NedbDatastore.ts index 189d2820..27e812bd 100644 --- a/src/datastore/NedbDatastore.ts +++ b/src/datastore/NedbDatastore.ts @@ -19,7 +19,7 @@ import { MatrixUser, EventStore, RoomStore, UserStore, StoredEvent } from "matrix-appservice-bridge"; -import { Datastore, UserEntry, RoomEntry, TeamEntry, EventEntry, EventEntryExtra } from "./Models"; +import { Datastore, UserEntry, RoomEntry, TeamEntry, EventEntry, EventEntryExtra, PuppetEntry } from "./Models"; import * as NedbDb from "nedb"; export class NedbDatastore implements Datastore { @@ -189,4 +189,29 @@ export class NedbDatastore implements Datastore { }); }); } + + public async setPuppetToken(): Promise { + // Puppeting not supported by NeDB - noop + return; + } + + public async removePuppetTokenByMatrixId() { + return; + } + + public async getPuppetTokenBySlackId(): Promise { + return null; + } + + public async getPuppetTokenByMatrixId(): Promise { + return null; + } + + public async getPuppetsByMatrixId(): Promise { + return []; + } + + public async getPuppetedUsers(): Promise<[]> { + return []; + } } diff --git a/src/datastore/postgres/PgDatastore.ts b/src/datastore/postgres/PgDatastore.ts index 666ba728..ee985a54 100644 --- a/src/datastore/postgres/PgDatastore.ts +++ b/src/datastore/postgres/PgDatastore.ts @@ -19,7 +19,7 @@ import * as pgInit from "pg-promise"; import { IDatabase, IMain } from "pg-promise"; import { Logging, MatrixUser } from "matrix-appservice-bridge"; -import { Datastore, TeamEntry, RoomEntry, UserEntry, EventEntry, EventEntryExtra } from "../Models"; +import { Datastore, TeamEntry, RoomEntry, UserEntry, EventEntry, EventEntryExtra, PuppetEntry } from "../Models"; import { BridgedRoom } from "../../BridgedRoom"; import { SlackGhost } from "../../SlackGhost"; @@ -30,7 +30,7 @@ const pgp: IMain = pgInit({ const log = Logging.get("PgDatastore"); export class PgDatastore implements Datastore { - public static readonly LATEST_SCHEMA = 1; + public static readonly LATEST_SCHEMA = 2; // tslint:disable-next-line: no-any public readonly postgresDb: IDatabase; @@ -190,6 +190,58 @@ export class PgDatastore implements Datastore { } as TeamEntry; } + public async setPuppetToken(teamId: string, slackUser: string, matrixId: string, token: string): Promise { + await this.postgresDb.none("INSERT INTO puppets VALUES (${slackUser}, ${teamId}, ${matrixId}, ${token})" + + "ON CONFLICT ON CONSTRAINT cons_puppets_uniq DO UPDATE SET token = ${token}", { + teamId, + slackUser, + matrixId, + token, + }); + } + + public async removePuppetTokenByMatrixId(teamId: string, matrixId: string) { + await this.postgresDb.none("DELETE FROM puppets WHERE slackteam = ${teamId} " + + "AND matrixuser = ${matrixId}", { teamId, matrixId }); + } + + public async getPuppetTokenBySlackId(teamId: string, slackId: string): Promise { + const res = await this.postgresDb.oneOrNone("SELECT token FROM puppets WHERE slackteam = ${teamId} " + + "AND slackuser = ${slackId}", { teamId, slackId }); + return res ? res.token : null; + } + + public async getPuppetTokenByMatrixId(teamId: string, matrixId: string): Promise { + const res = await this.postgresDb.oneOrNone( + "SELECT token FROM puppets WHERE slackteam = ${teamId} AND matrixuser = ${matrixId}", + { teamId, matrixId }, + ); + return res ? res.token : null; + } + + public async getPuppetsByMatrixId(userId: string): Promise { + return (await this.postgresDb.manyOrNone( + "SELECT * FROM puppets WHERE matrixuser = ${userId}", + { userId }, + )).map((u) => ({ + matrixId: u.matrixuser, + teamId: u.slackteam, + slackId: u.slackuser, + token: u.token, + })); + } + + public async getPuppetedUsers(): Promise { + return (await this.postgresDb.manyOrNone( + "SELECT * FROM puppets") + ).map((u) => ({ + matrixId: u.matrixuser, + teamId: u.slackteam, + slackId: u.slackuser, + token: u.token, + })); + } + private async updateSchemaVersion(version: number) { log.debug(`updateSchemaVersion: ${version}`); await this.postgresDb.none("UPDATE schema SET version = ${version};", {version}); diff --git a/src/datastore/postgres/schema/v2.ts b/src/datastore/postgres/schema/v2.ts new file mode 100644 index 00000000..bb4d6797 --- /dev/null +++ b/src/datastore/postgres/schema/v2.ts @@ -0,0 +1,13 @@ +import { IDatabase } from "pg-promise"; + +// tslint:disable-next-line: no-any +export async function runSchema(db: IDatabase) { + await db.none(`CREATE TABLE puppets ( + slackuser TEXT UNIQUE NOT NULL, + slackteam TEXT UNIQUE NOT NULL, + matrixuser TEXT UNIQUE NOT NULL, + token TEXT, + CONSTRAINT cons_puppets_uniq UNIQUE(slackuser, slackteam, matrixuser) + );`); + +} diff --git a/src/mocha.opts b/src/mocha.opts new file mode 100644 index 00000000..1e41a38a --- /dev/null +++ b/src/mocha.opts @@ -0,0 +1,3 @@ +--require ts-node/register +--require source-map-support/register +--recursive diff --git a/src/tests/integration/SharedDatastoreTests.ts b/src/tests/integration/SharedDatastoreTests.ts index 4a73f5a3..7b5c5a4d 100644 --- a/src/tests/integration/SharedDatastoreTests.ts +++ b/src/tests/integration/SharedDatastoreTests.ts @@ -34,6 +34,8 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void display_name: "A displayname", avatar_url: "Some avatar", id: "someid1", + slack_id: "foobar", + team_id: "barbaz", }, null), ); const userEntry = await ds().getUser("someid1"); @@ -41,23 +43,8 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void display_name: "A displayname", avatar_url: "Some avatar", id: "someid1", - }); - }); - - it("should be able to upsert a slack user", async () => { - const user = SlackGhost.fromEntry(null as any, { - display_name: "A displayname", - avatar_url: "Some avatar", - id: "someid2", - }, null); - await ds().upsertUser(user); - (user as any).displayName = "A changed displayname"; - await ds().upsertUser(user); - const userEntry = await ds().getUser("someid2"); - expect(userEntry).to.deep.equal({ - display_name: "A changed displayname", - avatar_url: "Some avatar", - id: "someid2", + slack_id: "foobar", + team_id: "barbaz", }); }); @@ -66,6 +53,8 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void display_name: "A displayname", avatar_url: "Some avatar", id: "someid3", + slack_id: "foobar", + team_id: "barbaz", }, null); await ds().upsertUser(user); (user as any).displayName = "A changed displayname"; @@ -75,6 +64,8 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void display_name: "A changed displayname", avatar_url: "Some avatar", id: "someid3", + slack_id: "foobar", + team_id: "barbaz", }); }); @@ -192,6 +183,7 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void slack_user_id: "a_user_id", slack_user_token: "a_user_token", slack_webhook_uri: "a_webhook_uri", + puppet_owner: "foobar", }, {} as any); await ds().upsertRoom(room); const rooms = await ds().getAllRooms(); @@ -213,6 +205,7 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void slack_user_id: "a_user_id", slack_user_token: "a_user_token", slack_webhook_uri: "a_webhook_uri", + puppet_owner: "foobar", }, {} as any); await ds().upsertRoom(room); room.SlackTeamDomain = "new_team_domain"; @@ -237,6 +230,7 @@ export const doDatastoreTests = (ds: () => Datastore, roomsAfterEach: () => void slack_user_id: "a_user_id", slack_user_token: "a_user_token", slack_webhook_uri: "a_webhook_uri", + puppet_owner: undefined, }, {} as any); await ds().upsertRoom(room); }