From 83ad75984fdccc7de8e973fd79fa34490327b1f0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 24 Oct 2018 20:29:39 -0600 Subject: [PATCH] Backend structures for Slack bridging Note that this doesn't include webhook bridging. For now Dimension is going to support event bridging as it is generally recommended. Rooms previously bridged with webhooks will be able to unbridge. --- docs/reference/scalar_server_api.md | 14 +- src/bridges/SlackBridge.ts | 284 ++++++++++++++++++ src/bridges/models/slack.ts | 48 +++ src/db/DimensionStore.ts | 2 + .../20181024200245-AddSlackBridge.ts | 22 ++ .../20181024200545-AddSlackBridgeRecord.ts | 23 ++ src/db/models/SlackBridgeRecord.ts | 26 ++ src/models/ModularResponses.ts | 7 + 8 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 src/bridges/SlackBridge.ts create mode 100644 src/bridges/models/slack.ts create mode 100644 src/db/migrations/20181024200245-AddSlackBridge.ts create mode 100644 src/db/migrations/20181024200545-AddSlackBridgeRecord.ts create mode 100644 src/db/models/SlackBridgeRecord.ts diff --git a/docs/reference/scalar_server_api.md b/docs/reference/scalar_server_api.md index 38a821e..5ecf0f0 100644 --- a/docs/reference/scalar_server_api.md +++ b/docs/reference/scalar_server_api.md @@ -359,7 +359,8 @@ None of these are officially documented, and are subject to change. "matrix_room_id":" !JmvocvDuPTYUfuvKgs:t2l.io", "slack_channel_name": "general", "team_id": "ABC...", - "status": "ready" + "status": "ready", + "slack_channel_id": "ABC..." } }] } @@ -413,7 +414,7 @@ None of these are officially documented, and are subject to change. ## POST `/api/bridges/slack/_matrix/provision/link?scalar_token=...` -**Body** +**Body (webhooks)** ``` { "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", @@ -421,6 +422,15 @@ None of these are officially documented, and are subject to change. } ``` +**Body (events)** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "channel_id": "ABC...", + "team_id": "ABC..." +} +``` + **Response (webhooks)** ``` { diff --git a/src/bridges/SlackBridge.ts b/src/bridges/SlackBridge.ts new file mode 100644 index 0000000..79330ee --- /dev/null +++ b/src/bridges/SlackBridge.ts @@ -0,0 +1,284 @@ +import IrcBridgeRecord from "../db/models/IrcBridgeRecord"; +import Upstream from "../db/models/Upstream"; +import UserScalarToken from "../db/models/UserScalarToken"; +import { LogService } from "matrix-js-snippets"; +import * as request from "request"; +import { ModularSlackResponse } from "../models/ModularResponses"; +import SlackBridgeRecord from "../db/models/SlackBridgeRecord"; +import { + BridgedChannelResponse, + ChannelsResponse, + GetBotUserIdResponse, + SlackChannel, + SlackTeam, + TeamsResponse +} from "./models/slack"; + +export interface SlackBridgeInfo { + botUserId: string; +} + +export interface BridgedChannel { + roomId: string; + isWebhook: boolean; + slackChannelName: string; + slackChannelId: string; + teamId: string; +} + +export class SlackBridge { + + constructor(private requestingUserId: string) { + } + + private async getDefaultBridge(): Promise { + const bridges = await SlackBridgeRecord.findAll({where: {isEnabled: true}}); + if (!bridges || bridges.length !== 1) { + throw new Error("No bridges or too many bridges found"); + } + return bridges[0]; + } + + public async isBridgingEnabled(): Promise { + const bridges = await SlackBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges; + } + + public async getBridgeInfo(): Promise { + const bridge = await this.getDefaultBridge(); + + if (bridge.upstreamId) { + const info = await this.doUpstreamRequest>(bridge, "POST", "/bridges/slack/_matrix/provision/getbotid/", null, {}); + if (!info || !info.replies || !info.replies[0] || !info.replies[0].response) { + throw new Error("Invalid response from Modular for Slack bot user ID"); + } + return {botUserId: info.replies[0].response.bot_user_id}; + } else { + const info = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/getbotid"); + return {botUserId: info.bot_user_id}; + } + } + + public async getLink(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + user_id: this.requestingUserId, + }; + try { + if (bridge.upstreamId) { + delete requestBody["user_id"]; + const link = await this.doUpstreamRequest>(bridge, "POST", "/bridges/slack/_matrix/provision/getlink", null, requestBody); + if (!link || !link.replies || !link.replies[0] || !link.replies[0].response) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Invalid response from Modular for Slack list links in " + roomId); + } + return { + roomId: link.replies[0].response.matrix_room_id, + isWebhook: link.replies[0].response.isWebhook, + slackChannelName: link.replies[0].response.slack_channel_name, + slackChannelId: link.replies[0].response.slack_channel_id, + teamId: link.replies[0].response.team_id, + }; + } else { + const link = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/getlink", null, requestBody); + return { + roomId: link.matrix_room_id, + isWebhook: link.isWebhook, + slackChannelName: link.slack_channel_name, + slackChannelId: link.slack_channel_id, + teamId: link.team_id, + }; + } + } catch (e) { + if (e.status === 404) return null; + LogService.error("SlackBridge", e); + throw e; + } + } + + public async requestEventsLink(roomId: string, channelId: string, teamId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + channel_id: channelId, + team_id: teamId, + user_id: this.requestingUserId, + }; + + if (bridge.upstreamId) { + delete requestBody["user_id"]; + await this.doUpstreamRequest(bridge, "POST", "/bridges/slack/_matrix/provision/link", null, requestBody); + } else { + await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/link", null, requestBody); + } + } + + public async removeEventsLink(roomId: string, channelId: string, teamId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + channel_id: channelId, + team_id: teamId, + user_id: this.requestingUserId, + }; + + if (bridge.upstreamId) { + delete requestBody["user_id"]; + await this.doUpstreamRequest(bridge, "POST", "/bridges/slack/_matrix/provision/unlink", null, requestBody); + } else { + await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/unlink", null, requestBody); + } + } + + public async removeWebhooksLink(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + user_id: this.requestingUserId, + }; + + if (bridge.upstreamId) { + delete requestBody["user_id"]; + await this.doUpstreamRequest(bridge, "POST", "/bridges/slack/_matrix/provision/unlink", null, requestBody); + } else { + await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/unlink", null, requestBody); + } + } + + public async getChannels(teamId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + team_id: teamId, + user_id: this.requestingUserId, + }; + + try { + if (bridge.upstreamId) { + delete requestBody["user_id"]; + const response = await this.doUpstreamRequest>(bridge, "POST", "/bridges/slack/_matrix/provision/channels", null, requestBody); + if (!response || !response.replies || !response.replies[0] || !response.replies[0].response) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Invalid response from Modular for Slack get channels of " + teamId); + } + return response.replies[0].response.channels; + } else { + const response = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/channels", null, requestBody); + return response.channels; + } + } catch (e) { + if (e.status === 404) return null; + LogService.error("SlackBridge", e); + throw e; + } + } + + public async getTeams(): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + user_id: this.requestingUserId, + }; + + try { + if (bridge.upstreamId) { + delete requestBody["user_id"]; + const response = await this.doUpstreamRequest>(bridge, "POST", "/bridges/slack/_matrix/provision/teams", null, requestBody); + if (!response || !response.replies || !response.replies[0] || !response.replies[0].response) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Invalid response from Modular for Slack get teams for " + this.requestingUserId); + } + return response.replies[0].response.teams; + } else { + const response = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/teams", null, requestBody); + return response.teams; + } + } catch (e) { + if (e.status === 404) return null; + LogService.error("SlackBridge", e); + throw e; + } + } + + private async doUpstreamRequest(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const upstream = await Upstream.findByPrimary(bridge.upstreamId); + const token = await UserScalarToken.findOne({ + where: { + upstreamId: upstream.id, + isDimensionToken: false, + userId: this.requestingUserId, + }, + }); + + if (!qs) qs = {}; + qs["scalar_token"] = token.scalarToken; + + const apiUrl = upstream.apiUrl.endsWith("/") ? upstream.apiUrl.substring(0, upstream.apiUrl.length - 1) : upstream.apiUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("SlackBridge", "Doing upstream Slack Bridge request: " + url); + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + }, (err, res, _body) => { + if (err) { + LogService.error("SlackBridge", "Error calling " + url); + LogService.error("SlackBridge", err); + reject(err); + } else if (!res) { + LogService.error("SlackBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200) { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + LogService.error("SlackBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("SlackBridge", res.body); + reject({body: res.body, status: res.statusCode}); + } else { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + }); + }); + } + + private async doProvisionRequest(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const provisionUrl = bridge.provisionUrl; + const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("SlackBridge", "Doing provision Slack Bridge request: " + url); + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + }, (err, res, _body) => { + if (err) { + LogService.error("SlackBridge", "Error calling" + url); + LogService.error("SlackBridge", err); + reject(err); + } else if (!res) { + LogService.error("SlackBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200) { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + LogService.error("SlackBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("SlackBridge", res.body); + reject({body: res.body, status: res.statusCode}); + } else { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/bridges/models/slack.ts b/src/bridges/models/slack.ts new file mode 100644 index 0000000..65c592d --- /dev/null +++ b/src/bridges/models/slack.ts @@ -0,0 +1,48 @@ +export interface GetBotUserIdResponse { + bot_user_id: string; +} + +export interface BridgedChannelResponse { + matrix_room_id: string; + auth_url?: string; + inbound_uri?: string; + isWebhook: boolean; + slack_webhook_uri?: string; + status: "pending" | "ready"; + slack_channel_name?: string; + team_id?: string; + slack_channel_id: string; +} + +export interface TeamsResponse { + teams: SlackTeam[]; +} + +export interface SlackTeam { + id: string; + name: string; + slack_id: string; +} + +export interface ChannelsResponse { + channels: SlackChannel[]; +} + +export interface SlackChannel { + id: string; + name: string; + purpose: { + creator: string; + last_set: number; + value: string; + }; + topic: { + creator: string; + last_set: number; + value: string; + }; +} + +export interface AuthUrlResponse { + auth_uri: string; +} \ No newline at end of file diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 6065b17..75fc9ed 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -25,6 +25,7 @@ import TelegramBridgeRecord from "./models/TelegramBridgeRecord"; import WebhookBridgeRecord from "./models/WebhookBridgeRecord"; import GitterBridgeRecord from "./models/GitterBridgeRecord"; import CustomSimpleBotRecord from "./models/CustomSimpleBotRecord"; +import SlackBridgeRecord from "./models/SlackBridgeRecord"; class _DimensionStore { private sequelize: Sequelize; @@ -61,6 +62,7 @@ class _DimensionStore { WebhookBridgeRecord, GitterBridgeRecord, CustomSimpleBotRecord, + SlackBridgeRecord, ]); } diff --git a/src/db/migrations/20181024200245-AddSlackBridge.ts b/src/db/migrations/20181024200245-AddSlackBridge.ts new file mode 100644 index 0000000..f5d4070 --- /dev/null +++ b/src/db/migrations/20181024200245-AddSlackBridge.ts @@ -0,0 +1,22 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.createTable("dimension_slack_bridges", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "upstreamId": { + type: DataType.INTEGER, allowNull: true, + references: {model: "dimension_upstreams", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "provisionUrl": {type: DataType.STRING, allowNull: true}, + "isEnabled": {type: DataType.BOOLEAN, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_slack_bridges")); + } +} \ No newline at end of file diff --git a/src/db/migrations/20181024200545-AddSlackBridgeRecord.ts b/src/db/migrations/20181024200545-AddSlackBridgeRecord.ts new file mode 100644 index 0000000..aa88c36 --- /dev/null +++ b/src/db/migrations/20181024200545-AddSlackBridgeRecord.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkInsert("dimension_bridges", [ + { + type: "slack", + name: "Slack Bridge", + avatarUrl: "/img/avatars/slack.png", + isEnabled: true, + isPublic: true, + description: "Bridges Slack channels to Matrix", + }, + ])); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkDelete("dimension_bridges", { + type: "slack", + })); + } +} \ No newline at end of file diff --git a/src/db/models/SlackBridgeRecord.ts b/src/db/models/SlackBridgeRecord.ts new file mode 100644 index 0000000..718969d --- /dev/null +++ b/src/db/models/SlackBridgeRecord.ts @@ -0,0 +1,26 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import Upstream from "./Upstream"; + +@Table({ + tableName: "dimension_slack_bridges", + underscoredAll: false, + timestamps: false, +}) +export default class SlackBridgeRecord extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @Column + isEnabled: boolean; +} \ No newline at end of file diff --git a/src/models/ModularResponses.ts b/src/models/ModularResponses.ts index c4b31e7..9322b48 100644 --- a/src/models/ModularResponses.ts +++ b/src/models/ModularResponses.ts @@ -15,4 +15,11 @@ export interface ModularGitterResponse { rid: string; response: T; }[]; +} + +export interface ModularSlackResponse { + replies: { + rid: string; + response: T; + }[]; } \ No newline at end of file