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