From b0abf7d38e1c0e6164de293e6219785fadbde2fc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 24 Oct 2018 19:50:23 -0600 Subject: [PATCH 1/4] Documentation for Scalar slack endpoints --- docs/reference/scalar_server_api.md | 262 ++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/docs/reference/scalar_server_api.md b/docs/reference/scalar_server_api.md index abc0d87..38a821e 100644 --- a/docs/reference/scalar_server_api.md +++ b/docs/reference/scalar_server_api.md @@ -322,6 +322,268 @@ None of these are officially documented, and are subject to change. } ``` +## POST `/api/bridges/slack/_matrix/provision/getlink/?scalar_token=...` + +**Body** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io" +} +``` + +**Response (webhooks)** +``` +{ + "replies": [{ + "rid": "...", + "response": { + "auth_uri": "https://slack.com/oauth/authorize?client_id=...", + "inbound_uri":" https://matrix.org/slackhook/...", + "isWebhook": true, + "matrix_room_id":" !JmvocvDuPTYUfuvKgs:t2l.io", + "slack_webhook_uri": "https://hooks.slack.com/...", + "status": "pending" + } + }] +} +``` + +**Response (events)** +``` +{ + "replies": [{ + "rid": "...", + "response": { + "inbound_uri":" https://matrix.org/slackhook/...", + "isWebhook": false, + "matrix_room_id":" !JmvocvDuPTYUfuvKgs:t2l.io", + "slack_channel_name": "general", + "team_id": "ABC...", + "status": "ready" + } + }] +} +``` + +*Note*: The `auth_uri` disappears after the user has authorized the bridge. This endpoint is also polled. This will also 404 if there is no link. + +## POST `/api/bridges/slack/_matrix/provision/getbotid?scalar_token=...` + +**Body** +``` +{} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "bot_user_id": "@slackbot:matrix.org" + } + } + ] +} +``` + +## POST `/api/bridges/slack/_matrix/provision/logout?scalar_token=...` + +**Body** +``` +{ + "slack_id": "ABC..." +} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "UNKNOWN": "RESPONSE" + } + } + ] +} +``` + +## POST `/api/bridges/slack/_matrix/provision/link?scalar_token=...` + +**Body** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "slack_webhook_url": "https://hooks.slack.com/..." +} +``` + +**Response (webhooks)** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "inbound_uri": "https://matrix.org/slackhook/...", + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "slack_webhook_uri": "https://hooks.slack.com/...", + "status": "pending" + } + } + ] +} +``` + +**Response (events)** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "inbound_uri": "https://matrix.org/slackhook/...", + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "slack_channel_name": "general", + "slack_channel_id": "ABC...", + "status": "ready" + } + } + ] +} +``` + +## POST `/api/bridges/slack/_matrix/provision/teams?scalar_token=...` + +**Body** +``` +{} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "teams": [ + { + "id": "ABC...", + "name": "turt2live", + "slack_id": "ABC..." + } + ] + } + } + ] +} +``` + +*Note*: This 404s if there's no teams set up. + +## POST `/api/bridges/slack/_matrix/provision/channels?scalar_token=...` + +**Body** +``` +{ + "team_id": "ABC..." +} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "channels": [ + { + "id": "ABC...", + "name":"general", + "purpose":{ + "creator":"", + "last_set":0, + "value":"This channel is for team-wide communication and announcements. All team members are in this channel." + }, + "topic":{ + "creator":"", + "last_set":0, + "value":"Company-wide announcements and work-based matters" + } + } + ] + } + } + ] +} +``` + +## POST `/api/bridges/slack/_matrix/provision/authurl?scalar_token=...` + +**Body** +``` +{} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "auth_uri": "https://slack.com/oauth/..." + } + } + ] +} +``` + +*Note*: This 404s if there's no teams set up. + +## POST `/api/bridges/gitter/_matrix/provision/unlink?scalar_token=...` + +**Body (webhooks)** +``` +{ + "inbound_uri":" https://matrix.org/slackhook/...", + "isWebhook": true, + "matrix_room_id":" !JmvocvDuPTYUfuvKgs:t2l.io", + "slack_webhook_uri": "https://hooks.slack.com/...", + "status": "pending" +} +``` + +**Body (events)** +``` +{ + "inbound_uri":" https://matrix.org/slackhook/...", + "isWebhook": false, + "matrix_room_id":" !JmvocvDuPTYUfuvKgs:t2l.io", + "slack_channel_id": "ABC...", + "slack_channel_name": "general", + "team_id": "ABC...", + "status": "ready" +} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": {} + } + ] +} +``` + ## POST `/api/bridges/gitter/_matrix/provision/getlink/?scalar_token=...` **Body** From 83ad75984fdccc7de8e973fd79fa34490327b1f0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 24 Oct 2018 20:29:39 -0600 Subject: [PATCH 2/4] 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 From 02e58e7a8dd2582d0d1fd67e34fa255f842d65d3 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 24 Oct 2018 20:56:38 -0600 Subject: [PATCH 3/4] Admin section for Slack bridges --- src/MemoryCache.ts | 3 +- src/api/admin/AdminSlackService.ts | 114 ++++++++++++++++++ src/bridges/SlackBridge.ts | 12 +- src/db/BridgeStore.ts | 25 ++-- src/integrations/Bridge.ts | 8 +- .../admin/bridges/gitter/gitter.component.ts | 3 +- .../manage-selfhosted.component.ts | 3 - .../manage-selfhosted.component.html | 24 ++++ .../manage-selfhosted.component.scss | 0 .../manage-selfhosted.component.ts | 53 ++++++++ .../admin/bridges/slack/slack.component.html | 45 +++++++ .../admin/bridges/slack/slack.component.scss | 3 + .../admin/bridges/slack/slack.component.ts | 102 ++++++++++++++++ web/app/app.module.ts | 7 ++ web/app/app.routing.ts | 6 + web/app/shared/models/slack.ts | 14 +++ .../services/admin/admin-slack-api.service.ts | 36 ++++++ 17 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 src/api/admin/AdminSlackService.ts create mode 100644 web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.html create mode 100644 web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.scss create mode 100644 web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.ts create mode 100644 web/app/admin/bridges/slack/slack.component.html create mode 100644 web/app/admin/bridges/slack/slack.component.scss create mode 100644 web/app/admin/bridges/slack/slack.component.ts create mode 100644 web/app/shared/models/slack.ts create mode 100644 web/app/shared/services/admin/admin-slack-api.service.ts diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts index b91dc1e..2b58dd0 100644 --- a/src/MemoryCache.ts +++ b/src/MemoryCache.ts @@ -52,4 +52,5 @@ export const CACHE_STICKERS = "stickers"; export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge"; export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge"; export const CACHE_GITTER_BRIDGE = "gitter-bridge"; -export const CACHE_SIMPLE_BOTS = "simple-bots"; \ No newline at end of file +export const CACHE_SIMPLE_BOTS = "simple-bots"; +export const CACHE_SLACK_BRIDGE = "slack-bridge"; \ No newline at end of file diff --git a/src/api/admin/AdminSlackService.ts b/src/api/admin/AdminSlackService.ts new file mode 100644 index 0000000..f4a2fe7 --- /dev/null +++ b/src/api/admin/AdminSlackService.ts @@ -0,0 +1,114 @@ +import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { AdminService } from "./AdminService"; +import { Cache, CACHE_INTEGRATIONS, CACHE_SLACK_BRIDGE } from "../../MemoryCache"; +import { LogService } from "matrix-js-snippets"; +import { ApiError } from "../ApiError"; +import Upstream from "../../db/models/Upstream"; +import SlackBridgeRecord from "../../db/models/SlackBridgeRecord"; + +interface CreateWithUpstream { + upstreamId: number; +} + +interface CreateSelfhosted { + provisionUrl: string; +} + +interface BridgeResponse { + id: number; + upstreamId?: number; + provisionUrl?: string; + isEnabled: boolean; +} + +/** + * Administrative API for configuring Slack bridge instances. + */ +@Path("/api/v1/dimension/admin/slack") +export class AdminSlackService { + + @GET + @Path("all") + public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridges = await SlackBridgeRecord.findAll(); + return Promise.all(bridges.map(async b => { + return { + id: b.id, + upstreamId: b.upstreamId, + provisionUrl: b.provisionUrl, + isEnabled: b.isEnabled, + }; + })); + } + + @GET + @Path(":bridgeId") + public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const telegramBridge = await SlackBridgeRecord.findByPrimary(bridgeId); + if (!telegramBridge) throw new ApiError(404, "Slack Bridge not found"); + + return { + id: telegramBridge.id, + upstreamId: telegramBridge.upstreamId, + provisionUrl: telegramBridge.provisionUrl, + isEnabled: telegramBridge.isEnabled, + }; + } + + @POST + @Path(":bridgeId") + public async updateBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await SlackBridgeRecord.findByPrimary(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + await bridge.save(); + + LogService.info("AdminSlackService", userId + " updated Slack Bridge " + bridge.id); + + Cache.for(CACHE_SLACK_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } + + @POST + @Path("new/upstream") + public async newConfigForUpstream(@QueryParam("scalar_token") scalarToken: string, request: CreateWithUpstream): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const upstream = await Upstream.findByPrimary(request.upstreamId); + if (!upstream) throw new ApiError(400, "Upstream not found"); + + const bridge = await SlackBridgeRecord.create({ + upstreamId: request.upstreamId, + isEnabled: true, + }); + LogService.info("AdminSlackService", userId + " created a new Slack Bridge from upstream " + request.upstreamId); + + Cache.for(CACHE_SLACK_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } + + @POST + @Path("new/selfhosted") + public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await SlackBridgeRecord.create({ + provisionUrl: request.provisionUrl, + isEnabled: true, + }); + LogService.info("AdminSlackService", userId + " created a new Slack Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_SLACK_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } +} \ No newline at end of file diff --git a/src/bridges/SlackBridge.ts b/src/bridges/SlackBridge.ts index 79330ee..2f27d84 100644 --- a/src/bridges/SlackBridge.ts +++ b/src/bridges/SlackBridge.ts @@ -21,8 +21,8 @@ export interface SlackBridgeInfo { export interface BridgedChannel { roomId: string; isWebhook: boolean; - slackChannelName: string; - slackChannelId: string; + channelName: string; + channelId: string; teamId: string; } @@ -77,8 +77,8 @@ export class SlackBridge { 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, + channelName: link.replies[0].response.slack_channel_name, + channelId: link.replies[0].response.slack_channel_id, teamId: link.replies[0].response.team_id, }; } else { @@ -86,8 +86,8 @@ export class SlackBridge { return { roomId: link.matrix_room_id, isWebhook: link.isWebhook, - slackChannelName: link.slack_channel_name, - slackChannelId: link.slack_channel_id, + channelName: link.slack_channel_name, + channelId: link.slack_channel_id, teamId: link.team_id, }; } diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index 229c4aa..60ecb9b 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,6 +1,7 @@ import { Bridge, GitterBridgeConfiguration, + SlackBridgeConfiguration, TelegramBridgeConfiguration, WebhookBridgeConfiguration } from "../integrations/Bridge"; @@ -10,6 +11,7 @@ import { LogService } from "matrix-js-snippets"; import { TelegramBridge } from "../bridges/TelegramBridge"; import { WebhooksBridge } from "../bridges/WebhooksBridge"; import { GitterBridge } from "../bridges/GitterBridge"; +import { SlackBridge } from "../bridges/SlackBridge"; export class BridgeStore { @@ -50,14 +52,9 @@ export class BridgeStore { const record = await BridgeRecord.findOne({where: {type: integrationType}}); if (!record) throw new Error("Bridge not found"); - if (integrationType === "irc") { - throw new Error("IRC Bridges should be modified with the dedicated API"); - } else if (integrationType === "telegram") { - throw new Error("Telegram bridges should be modified with the dedicated API"); - } else if (integrationType === "webhooks") { - throw new Error("Webhooks should be modified with the dedicated API"); - } else if (integrationType === "gitter") { - throw new Error("Gitter Bridges should be modified with the dedicated API"); + const hasDedicatedApi = ["irc", "telegram", "webhooks", "gitter", "slack"]; + if (hasDedicatedApi.indexOf(integrationType) !== -1) { + throw new Error("This bridge should be modified with the dedicated API"); } else throw new Error("Unsupported bridge"); } @@ -74,6 +71,9 @@ export class BridgeStore { } else if (record.type === "gitter") { const gitter = new GitterBridge(requestingUserId); return gitter.isBridgingEnabled(); + } else if (record.type === "slack") { + const slack = new SlackBridge(requestingUserId); + return slack.isBridgingEnabled(); } else return true; } @@ -111,6 +111,15 @@ export class BridgeStore { link: link, botUserId: info.botUserId, }; + } else if (record.type === "slack") { + if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs + const slack = new SlackBridge(requestingUserId); + const info = await slack.getBridgeInfo(); + const link = await slack.getLink(inRoomId); + return { + link: link, + botUserId: info.botUserId, + }; } else return {}; } diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index 9233249..4685fa0 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -4,6 +4,7 @@ import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge"; import { WebhookConfiguration } from "../bridges/models/webhooks"; import { BridgedRoom } from "../bridges/GitterBridge"; +import { BridgedChannel } from "../bridges/SlackBridge"; const PRIVATE_ACCESS_SUPPORTED_BRIDGES = ["webhooks", "gitter"]; @@ -42,6 +43,11 @@ export interface WebhookBridgeConfiguration { } export interface GitterBridgeConfiguration { - link: BridgedRoom, + link: BridgedRoom; + botUserId: string; +} + +export interface SlackBridgeConfiguration { + link: BridgedChannel; botUserId: string; } \ No newline at end of file diff --git a/web/app/admin/bridges/gitter/gitter.component.ts b/web/app/admin/bridges/gitter/gitter.component.ts index be908e8..e5f60eb 100644 --- a/web/app/admin/bridges/gitter/gitter.component.ts +++ b/web/app/admin/bridges/gitter/gitter.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { ToasterService } from "angular2-toaster"; import { Modal, overlayConfigFactory } from "ngx-modialog"; -import { FE_TelegramBridge } from "../../../shared/models/telegram"; import { AdminGitterBridgeManageSelfhostedComponent, ManageSelfhostedGitterBridgeDialogContext @@ -86,7 +85,7 @@ export class AdminGitterBridgeComponent implements OnInit { }); } - public editBridge(bridge: FE_TelegramBridge) { + public editBridge(bridge: FE_GitterBridge) { this.modal.open(AdminGitterBridgeManageSelfhostedComponent, overlayConfigFactory({ isBlocking: true, size: 'lg', diff --git a/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts index 17b1df3..511d889 100644 --- a/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts +++ b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts @@ -6,9 +6,6 @@ import { AdminGitterApiService } from "../../../../shared/services/admin/admin-g export class ManageSelfhostedGitterBridgeDialogContext extends BSModalContext { public provisionUrl: string; - public sharedSecret: string; - public allowTgPuppets = false; - public allowMxPuppets = false; public bridgeId: number; } diff --git a/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..6e33e49 --- /dev/null +++ b/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,24 @@ +
+
+

{{ isAdding ? "Add a new" : "Edit" }} self-hosted Slack bridge

+
+
+

Self-hosted Slack bridges already have provisioning enabled. Be careful not to expose the API to the public internet.

+ + +
+ +
\ No newline at end of file diff --git a/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..25e452e --- /dev/null +++ b/web/app/admin/bridges/slack/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,53 @@ +import { Component } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; +import { AdminGitterApiService } from "../../../../shared/services/admin/admin-gitter-api.service"; + +export class ManageSelfhostedSlackBridgeDialogContext extends BSModalContext { + public provisionUrl: string; + public bridgeId: number; +} + +@Component({ + templateUrl: "./manage-selfhosted.component.html", + styleUrls: ["./manage-selfhosted.component.scss"], +}) +export class AdminSlackBridgeManageSelfhostedComponent implements ModalComponent { + + public isSaving = false; + public provisionUrl: string; + public bridgeId: number; + public isAdding = false; + + constructor(public dialog: DialogRef, + private gitterApi: AdminGitterApiService, + private toaster: ToasterService) { + this.provisionUrl = dialog.context.provisionUrl; + this.bridgeId = dialog.context.bridgeId; + this.isAdding = !this.bridgeId; + } + + public add() { + this.isSaving = true; + if (this.isAdding) { + this.gitterApi.newSelfhosted(this.provisionUrl).then(() => { + this.toaster.pop("success", "Slack bridge added"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to create Slack bridge"); + }); + } else { + this.gitterApi.updateSelfhosted(this.bridgeId, this.provisionUrl).then(() => { + this.toaster.pop("success", "Slack bridge updated"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to update Slack bridge"); + }); + } + } +} diff --git a/web/app/admin/bridges/slack/slack.component.html b/web/app/admin/bridges/slack/slack.component.html new file mode 100644 index 0000000..d2eba48 --- /dev/null +++ b/web/app/admin/bridges/slack/slack.component.html @@ -0,0 +1,45 @@ +
+ +
+
+ +
+

+ matrix-appservice-slack + is a Slack bridge that supports bridging Slack channels to Matrix. Users authorize the bridge to access their + Slack workspaces and from there they can pick the channels they'd like to bridge. +

+ + + + + + + + + + + + + + + + + +
NameActions
No bridge configurations.
+ {{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }} + ({{ bridge.provisionUrl }}) + + + + +
+ + +
+
+
\ No newline at end of file diff --git a/web/app/admin/bridges/slack/slack.component.scss b/web/app/admin/bridges/slack/slack.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/slack/slack.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/slack/slack.component.ts b/web/app/admin/bridges/slack/slack.component.ts new file mode 100644 index 0000000..1654b7d --- /dev/null +++ b/web/app/admin/bridges/slack/slack.component.ts @@ -0,0 +1,102 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { FE_Upstream } from "../../../shared/models/admin-responses"; +import { AdminUpstreamApiService } from "../../../shared/services/admin/admin-upstream-api.service"; +import { + AdminSlackBridgeManageSelfhostedComponent, + ManageSelfhostedSlackBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { FE_SlackBridge } from "../../../shared/models/slack"; +import { AdminSlackApiService } from "../../../shared/services/admin/admin-slack-api.service"; + +@Component({ + templateUrl: "./slack.component.html", + styleUrls: ["./slack.component.scss"], +}) +export class AdminSlackBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_SlackBridge[] = []; + + private upstreams: FE_Upstream[]; + + constructor(private slackApi: AdminSlackApiService, + private upstreamApi: AdminUpstreamApiService, + private toaster: ToasterService, + private modal: Modal) { + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.upstreams = await this.upstreamApi.getUpstreams(); + this.configurations = await this.slackApi.getBridges(); + } catch (err) { + console.error(err); + this.toaster.pop("error", "Error loading bridges"); + } + } + + public addModularHostedBridge() { + this.isUpdating = true; + + const createBridge = (upstream: FE_Upstream) => { + return this.slackApi.newFromUpstream(upstream).then(bridge => { + this.configurations.push(bridge); + this.toaster.pop("success", "matrix.org's Slack bridge added"); + this.isUpdating = false; + }).catch(err => { + console.error(err); + this.isUpdating = false; + this.toaster.pop("error", "Error adding matrix.org's Slack Bridge"); + }); + }; + + const vectorUpstreams = this.upstreams.filter(u => u.type === "vector"); + if (vectorUpstreams.length === 0) { + console.log("Creating default scalar upstream"); + const scalarUrl = "https://scalar.vector.im/api"; + this.upstreamApi.newUpstream("modular", "vector", scalarUrl, scalarUrl).then(upstream => { + this.upstreams.push(upstream); + createBridge(upstream); + }).catch(err => { + console.error(err); + this.toaster.pop("error", "Error creating matrix.org's Slack Bridge"); + }); + } else createBridge(vectorUpstreams[0]); + } + + public addSelfHostedBridge() { + this.modal.open(AdminSlackBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: '', + }, ManageSelfhostedSlackBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Slack bridge list"); + }); + }); + } + + public editBridge(bridge: FE_SlackBridge) { + this.modal.open(AdminSlackBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: bridge.provisionUrl, + bridgeId: bridge.id, + }, ManageSelfhostedSlackBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Slack bridge list"); + }); + }); + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 001a441..480f023 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -104,6 +104,9 @@ import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify import { AdminCustomSimpleBotsApiService } from "./shared/services/admin/admin-custom-simple-bots-api.service"; import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component"; import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.component"; +import { AdminSlackBridgeManageSelfhostedComponent } from "./admin/bridges/slack/manage-selfhosted/manage-selfhosted.component"; +import { AdminSlackBridgeComponent } from "./admin/bridges/slack/slack.component"; +import { AdminSlackApiService } from "./shared/services/admin/admin-slack-api.service"; @NgModule({ imports: [ @@ -190,6 +193,8 @@ import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.componen SpotifyWidgetWrapperComponent, AdminCustomBotsComponent, AdminAddCustomBotComponent, + AdminSlackBridgeManageSelfhostedComponent, + AdminSlackBridgeComponent, // Vendor ], @@ -216,6 +221,7 @@ import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.componen AdminGitterApiService, GitterApiService, AdminCustomSimpleBotsApiService, + AdminSlackApiService, {provide: Window, useValue: window}, // Vendor @@ -239,6 +245,7 @@ import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.componen AdminWebhooksBridgeManageSelfhostedComponent, AdminGitterBridgeManageSelfhostedComponent, AdminAddCustomBotComponent, + AdminSlackBridgeManageSelfhostedComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 75a7674..b600868 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -40,6 +40,7 @@ import { TradingViewWidgetWrapperComponent } from "./widget-wrappers/tradingview import { SpotifyWidgetConfigComponent } from "./configs/widget/spotify/spotify.widget.component"; import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify.component"; import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component"; +import { AdminSlackBridgeComponent } from "./admin/bridges/slack/slack.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -125,6 +126,11 @@ const routes: Routes = [ component: AdminGitterBridgeComponent, data: {breadcrumb: "Gitter Bridge", name: "Gitter Bridge"}, }, + { + path: "slack", + component: AdminSlackBridgeComponent, + data: {breadcrumb: "Slack Bridge", name: "Slack Bridge"}, + }, ], }, { diff --git a/web/app/shared/models/slack.ts b/web/app/shared/models/slack.ts new file mode 100644 index 0000000..6e37a63 --- /dev/null +++ b/web/app/shared/models/slack.ts @@ -0,0 +1,14 @@ +export interface FE_SlackBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + isEnabled: boolean; +} + +export interface FE_SlackLink { + roomId: string; + isWebhook: boolean; + channelName: string; + channelId: string; + teamId: string; +} \ No newline at end of file diff --git a/web/app/shared/services/admin/admin-slack-api.service.ts b/web/app/shared/services/admin/admin-slack-api.service.ts new file mode 100644 index 0000000..b3c47a0 --- /dev/null +++ b/web/app/shared/services/admin/admin-slack-api.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { FE_SlackBridge } from "../../models/slack"; + +@Injectable() +export class AdminSlackApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/slack/all").map(r => r.json()).toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/slack/" + bridgeId).map(r => r.json()).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/slack/new/upstream", {upstreamId: upstream.id}).map(r => r.json()).toPromise(); + } + + public newSelfhosted(provisionUrl: string): Promise { + return this.authedPost("/api/v1/dimension/admin/slack/new/selfhosted", { + provisionUrl: provisionUrl, + }).map(r => r.json()).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string): Promise { + return this.authedPost("/api/v1/dimension/admin/slack/" + bridgeId, { + provisionUrl: provisionUrl, + }).map(r => r.json()).toPromise(); + } +} From 99e0647cd740c75a2c464208e396b25101404c7d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 24 Oct 2018 22:49:29 -0600 Subject: [PATCH 4/4] Self-service Slack provisioning Fixes https://github.com/turt2live/matrix-dimension/issues/5 Fixes https://github.com/turt2live/matrix-dimension/issues/8 --- src/api/dimension/DimensionSlackService.ts | 104 ++++++++++++++ src/bridges/SlackBridge.ts | 32 ++++- web/app/app.module.ts | 4 + web/app/app.routing.ts | 6 + .../bridge/slack/slack.bridge.component.html | 59 ++++++++ .../bridge/slack/slack.bridge.component.scss | 9 ++ .../bridge/slack/slack.bridge.component.ts | 129 ++++++++++++++++++ web/app/shared/models/slack.ts | 10 ++ .../shared/registry/integrations.registry.ts | 1 + .../integrations/slack-api.service.ts | 38 ++++++ web/public/img/avatars/slack.png | Bin 0 -> 13807 bytes web/public/img/slack_auth_button.png | Bin 0 -> 6248 bytes 12 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 src/api/dimension/DimensionSlackService.ts create mode 100644 web/app/configs/bridge/slack/slack.bridge.component.html create mode 100644 web/app/configs/bridge/slack/slack.bridge.component.scss create mode 100644 web/app/configs/bridge/slack/slack.bridge.component.ts create mode 100644 web/app/shared/services/integrations/slack-api.service.ts create mode 100644 web/public/img/avatars/slack.png create mode 100644 web/public/img/slack_auth_button.png diff --git a/src/api/dimension/DimensionSlackService.ts b/src/api/dimension/DimensionSlackService.ts new file mode 100644 index 0000000..aa95771 --- /dev/null +++ b/src/api/dimension/DimensionSlackService.ts @@ -0,0 +1,104 @@ +import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { ScalarService } from "../scalar/ScalarService"; +import { ApiError } from "../ApiError"; +import { LogService } from "matrix-js-snippets"; +import { BridgedChannel, SlackBridge } from "../../bridges/SlackBridge"; +import { SlackChannel, SlackTeam } from "../../bridges/models/slack"; + +interface BridgeRoomRequest { + teamId: string; + channelId: string; +} + +/** + * API for interacting with the Slack bridge + */ +@Path("/api/v1/dimension/slack") +export class DimensionSlackService { + + @GET + @Path("room/:roomId/link") + public async getLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const slack = new SlackBridge(userId); + return slack.getLink(roomId); + } catch (e) { + LogService.error("DimensionSlackService", e); + throw new ApiError(400, "Error getting bridge info"); + } + } + + @POST + @Path("room/:roomId/link") + public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const slack = new SlackBridge(userId); + await slack.requestEventsLink(roomId, request.teamId, request.channelId); + return slack.getLink(roomId); + } catch (e) { + LogService.error("DimensionSlackService", e); + throw new ApiError(400, "Error bridging room"); + } + } + + @DELETE + @Path("room/:roomId/link") + public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const slack = new SlackBridge(userId); + const link = await slack.getLink(roomId); + if (link.isWebhook) await slack.removeWebhooksLink(roomId); + else await slack.removeEventsLink(roomId, link.teamId, link.channelId); + return {}; // 200 OK + } catch (e) { + LogService.error("DimensionSlackService", e); + throw new ApiError(400, "Error unbridging room"); + } + } + + @GET + @Path("teams") + public async getTeams(@QueryParam("scalar_token") scalarToken: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + const slack = new SlackBridge(userId); + const teams = await slack.getTeams(); + if (!teams) throw new ApiError(404, "No teams found"); + return teams; + } + + @GET + @Path("teams/:teamId/channels") + public async getChannels(@QueryParam("scalar_token") scalarToken: string, @PathParam("teamId") teamId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const slack = new SlackBridge(userId); + return slack.getChannels(teamId); + } catch (e) { + LogService.error("DimensionSlackService", e); + throw new ApiError(400, "Error getting channel info"); + } + } + + @GET + @Path("auth") + public async getAuthUrl(@QueryParam("scalar_token") scalarToken: string): Promise<{ authUrl: string }> { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const slack = new SlackBridge(userId); + const authUrl = await slack.getAuthUrl(); + return {authUrl}; + } catch (e) { + LogService.error("DimensionSlackService", e); + throw new ApiError(400, "Error getting auth info"); + } + } +} \ No newline at end of file diff --git a/src/bridges/SlackBridge.ts b/src/bridges/SlackBridge.ts index 2f27d84..d718501 100644 --- a/src/bridges/SlackBridge.ts +++ b/src/bridges/SlackBridge.ts @@ -6,6 +6,7 @@ import * as request from "request"; import { ModularSlackResponse } from "../models/ModularResponses"; import SlackBridgeRecord from "../db/models/SlackBridgeRecord"; import { + AuthUrlResponse, BridgedChannelResponse, ChannelsResponse, GetBotUserIdResponse, @@ -98,7 +99,7 @@ export class SlackBridge { } } - public async requestEventsLink(roomId: string, channelId: string, teamId: string): Promise { + public async requestEventsLink(roomId: string, teamId: string, channelId: string): Promise { const bridge = await this.getDefaultBridge(); const requestBody = { @@ -116,7 +117,7 @@ export class SlackBridge { } } - public async removeEventsLink(roomId: string, channelId: string, teamId: string): Promise { + public async removeEventsLink(roomId: string, teamId: string, channelId: string): Promise { const bridge = await this.getDefaultBridge(); const requestBody = { @@ -205,6 +206,33 @@ export class SlackBridge { } } + public async getAuthUrl(): 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/authurl", 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 auth url for " + this.requestingUserId); + } + return response.replies[0].response.auth_uri; + } else { + const response = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/authurl", null, requestBody); + return response.auth_uri; + } + } 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({ diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 480f023..deecc52 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -104,6 +104,8 @@ import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify import { AdminCustomSimpleBotsApiService } from "./shared/services/admin/admin-custom-simple-bots-api.service"; import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component"; import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.component"; +import { SlackApiService } from "./shared/services/integrations/slack-api.service"; +import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge.component"; import { AdminSlackBridgeManageSelfhostedComponent } from "./admin/bridges/slack/manage-selfhosted/manage-selfhosted.component"; import { AdminSlackBridgeComponent } from "./admin/bridges/slack/slack.component"; import { AdminSlackApiService } from "./shared/services/admin/admin-slack-api.service"; @@ -193,6 +195,7 @@ import { AdminSlackApiService } from "./shared/services/admin/admin-slack-api.se SpotifyWidgetWrapperComponent, AdminCustomBotsComponent, AdminAddCustomBotComponent, + SlackBridgeConfigComponent, AdminSlackBridgeManageSelfhostedComponent, AdminSlackBridgeComponent, @@ -221,6 +224,7 @@ import { AdminSlackApiService } from "./shared/services/admin/admin-slack-api.se AdminGitterApiService, GitterApiService, AdminCustomSimpleBotsApiService, + SlackApiService, AdminSlackApiService, {provide: Window, useValue: window}, diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index b600868..db55507 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -41,6 +41,7 @@ import { SpotifyWidgetConfigComponent } from "./configs/widget/spotify/spotify.w import { SpotifyWidgetWrapperComponent } from "./widget-wrappers/spotify/spotify.component"; import { AdminCustomBotsComponent } from "./admin/custom-bots/custom-bots.component"; import { AdminSlackBridgeComponent } from "./admin/bridges/slack/slack.component"; +import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -238,6 +239,11 @@ const routes: Routes = [ component: GitterBridgeConfigComponent, data: {breadcrumb: "Gitter Bridge Configuration", name: "Gitter Bridge Configuration"}, }, + { + path: "slack", + component: SlackBridgeConfigComponent, + data: {breadcrumb: "Slack Bridge Configuration", name: "Slack Bridge Configuration"}, + }, ], }, { diff --git a/web/app/configs/bridge/slack/slack.bridge.component.html b/web/app/configs/bridge/slack/slack.bridge.component.html new file mode 100644 index 0000000..31d248a --- /dev/null +++ b/web/app/configs/bridge/slack/slack.bridge.component.html @@ -0,0 +1,59 @@ + + + +
+ Bridge to Slack +
+
+ +
+
+
+ This room is bridged to Slack using webhooks. Webhook bridging is legacy and doesn't support as + rich bridging as the new approach. It is recommended to re-create the bridge with the new process. +
+ +
+
+ This room is bridged to "{{ bridge.config.link.channelName }}" on Slack. + +
+
+

+ In order to bridge Slack channels, you'll need to authorize the bridge to access your teams + and channels. Please click the button below to do so. +

+ + sign in with slack + +
+
+ + + +
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/bridge/slack/slack.bridge.component.scss b/web/app/configs/bridge/slack/slack.bridge.component.scss new file mode 100644 index 0000000..48e8513 --- /dev/null +++ b/web/app/configs/bridge/slack/slack.bridge.component.scss @@ -0,0 +1,9 @@ +.actions-col { + width: 120px; + text-align: center; +} + +.slack-auth-button { + width: 170px; + height: 40px; +} \ No newline at end of file diff --git a/web/app/configs/bridge/slack/slack.bridge.component.ts b/web/app/configs/bridge/slack/slack.bridge.component.ts new file mode 100644 index 0000000..d957dfa --- /dev/null +++ b/web/app/configs/bridge/slack/slack.bridge.component.ts @@ -0,0 +1,129 @@ +import { Component, OnInit } from "@angular/core"; +import { BridgeComponent } from "../bridge.component"; +import { FE_SlackChannel, FE_SlackLink, FE_SlackTeam } from "../../../shared/models/slack"; +import { SlackApiService } from "../../../shared/services/integrations/slack-api.service"; +import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service"; +import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; + +interface SlackConfig { + botUserId: string; + link: FE_SlackLink; +} + +@Component({ + templateUrl: "slack.bridge.component.html", + styleUrls: ["slack.bridge.component.scss"], +}) +export class SlackBridgeConfigComponent extends BridgeComponent implements OnInit { + + public teamId: string; + public channelId: string; + public teams: FE_SlackTeam[]; + public channels: FE_SlackChannel[]; + public isBusy: boolean; + public loadingTeams = true; + public needsAuth = false; + public authUrl: SafeUrl; + + private timerId: any; + + constructor(private slack: SlackApiService, private scalar: ScalarClientApiService, private sanitizer: DomSanitizer) { + super("slack"); + } + + public ngOnInit() { + super.ngOnInit(); + + this.tryLoadTeams(); + } + + private tryLoadTeams() { + this.slack.getTeams().then(teams => { + this.teams = teams; + this.teamId = this.teams[0].id; + this.needsAuth = false; + this.loadingTeams = false; + this.loadChannels(); + + if (this.timerId) { + clearInterval(this.timerId); + } + }).catch(error => { + if (error.status === 404) { + this.needsAuth = true; + + if (!this.authUrl) { + this.slack.getAuthUrl().then(url => { + this.authUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); + this.loadingTeams = false; + }).catch(error2 => { + console.error(error2); + this.toaster.pop("error", "Error getting Slack authorization information"); + }); + + this.timerId = setInterval(() => { + this.tryLoadTeams(); + }, 1000); + } + } else { + console.error(error); + this.toaster.pop("error", "Error getting teams"); + } + }); + } + + public get isBridged(): boolean { + return !!this.bridge.config.link; + } + + public loadChannels() { + this.isBusy = true; + this.slack.getChannels(this.teamId).then(channels => { + this.channels = channels; + this.channelId = this.channels[0].id; + this.isBusy = false; + }).catch(error => { + console.error(error); + this.toaster.pop("error", "Error getting channels for team"); + this.isBusy = false; + }); + } + + public async bridgeRoom(): Promise { + this.isBusy = true; + + try { + await this.scalar.inviteUser(this.roomId, this.bridge.config.botUserId); + } catch (e) { + if (!e.response || !e.response.error || !e.response.error._error || + e.response.error._error.message.indexOf("already in the room") === -1) { + this.isBusy = false; + this.toaster.pop("error", "Error inviting bridge"); + return; + } + } + + this.slack.bridgeRoom(this.roomId, this.teamId, this.channelId).then(link => { + this.bridge.config.link = link; + this.isBusy = false; + this.toaster.pop("success", "Bridge requested"); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.toaster.pop("error", "Error requesting bridge"); + }); + } + + public unbridgeRoom(): void { + this.isBusy = true; + this.slack.unbridgeRoom(this.roomId).then(() => { + this.bridge.config.link = null; + this.isBusy = false; + this.toaster.pop("success", "Bridge removed"); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.toaster.pop("error", "Error removing bridge"); + }); + } +} \ No newline at end of file diff --git a/web/app/shared/models/slack.ts b/web/app/shared/models/slack.ts index 6e37a63..cebaa29 100644 --- a/web/app/shared/models/slack.ts +++ b/web/app/shared/models/slack.ts @@ -11,4 +11,14 @@ export interface FE_SlackLink { channelName: string; channelId: string; teamId: string; +} + +export interface FE_SlackTeam { + id: string; + name: string; +} + +export interface FE_SlackChannel { + id: string; + name: string; } \ No newline at end of file diff --git a/web/app/shared/registry/integrations.registry.ts b/web/app/shared/registry/integrations.registry.ts index 5d2e916..e301513 100644 --- a/web/app/shared/registry/integrations.registry.ts +++ b/web/app/shared/registry/integrations.registry.ts @@ -27,6 +27,7 @@ export class IntegrationsRegistry { "telegram": {}, "webhooks": {}, "gitter": {}, + "slack": {}, }, "widget": { "custom": { diff --git a/web/app/shared/services/integrations/slack-api.service.ts b/web/app/shared/services/integrations/slack-api.service.ts new file mode 100644 index 0000000..43878ea --- /dev/null +++ b/web/app/shared/services/integrations/slack-api.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_SlackChannel, FE_SlackLink, FE_SlackTeam } from "../../models/slack"; + +@Injectable() +export class SlackApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public bridgeRoom(roomId: string, teamId: string, channelId: string): Promise { + return this.authedPost("/api/v1/dimension/slack/room/" + roomId + "/link", {teamId, channelId}) + .map(r => r.json()).toPromise(); + } + + public unbridgeRoom(roomId: string): Promise { + return this.authedDelete("/api/v1/dimension/slack/room/" + roomId + "/link") + .map(r => r.json()).toPromise(); + } + + public getLink(roomId: string): Promise { + return this.authedGet("/api/v1/dimension/slack/room/" + roomId + "/link") + .map(r => r.json()).toPromise(); + } + + public getTeams(): Promise { + return this.authedGet("/api/v1/dimension/slack/teams").map(r => r.json()).toPromise(); + } + + public getChannels(teamId: string): Promise { + return this.authedGet("/api/v1/dimension/slack/teams/" + teamId + "/channels").map(r => r.json()).toPromise(); + } + + public getAuthUrl(): Promise { + return this.authedGet("/api/v1/dimension/slack/auth").map(r => r.json()).toPromise().then(r => r["authUrl"]); + } +} \ No newline at end of file diff --git a/web/public/img/avatars/slack.png b/web/public/img/avatars/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..1a95146caf496b8672675ea6ca1cf0eb87ff8777 GIT binary patch literal 13807 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 literal 0 HcmV?d00001 diff --git a/web/public/img/slack_auth_button.png b/web/public/img/slack_auth_button.png new file mode 100644 index 0000000000000000000000000000000000000000..25cb7ba91b405065084f06b6f0d03dea53324b14 GIT binary patch literal 6248 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 literal 0 HcmV?d00001