diff --git a/docs/reference/scalar_server_api.md b/docs/reference/scalar_server_api.md index abc0d87..5ecf0f0 100644 --- a/docs/reference/scalar_server_api.md +++ b/docs/reference/scalar_server_api.md @@ -322,6 +322,278 @@ 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", + "slack_channel_id": "ABC..." + } + }] +} +``` + +*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 (webhooks)** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "slack_webhook_url": "https://hooks.slack.com/..." +} +``` + +**Body (events)** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "channel_id": "ABC...", + "team_id": "ABC..." +} +``` + +**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** 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/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 new file mode 100644 index 0000000..d718501 --- /dev/null +++ b/src/bridges/SlackBridge.ts @@ -0,0 +1,312 @@ +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 { + AuthUrlResponse, + BridgedChannelResponse, + ChannelsResponse, + GetBotUserIdResponse, + SlackChannel, + SlackTeam, + TeamsResponse +} from "./models/slack"; + +export interface SlackBridgeInfo { + botUserId: string; +} + +export interface BridgedChannel { + roomId: string; + isWebhook: boolean; + channelName: string; + channelId: 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, + channelName: link.replies[0].response.slack_channel_name, + channelId: 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, + channelName: link.slack_channel_name, + channelId: 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, teamId: string, channelId: 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, teamId: string, channelId: 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; + } + } + + 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({ + 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/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/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/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/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 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..deecc52 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -104,6 +104,11 @@ 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"; @NgModule({ imports: [ @@ -190,6 +195,9 @@ import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.componen SpotifyWidgetWrapperComponent, AdminCustomBotsComponent, AdminAddCustomBotComponent, + SlackBridgeConfigComponent, + AdminSlackBridgeManageSelfhostedComponent, + AdminSlackBridgeComponent, // Vendor ], @@ -216,6 +224,8 @@ import { AdminAddCustomBotComponent } from "./admin/custom-bots/add/add.componen AdminGitterApiService, GitterApiService, AdminCustomSimpleBotsApiService, + SlackApiService, + AdminSlackApiService, {provide: Window, useValue: window}, // Vendor @@ -239,6 +249,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..db55507 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -40,6 +40,8 @@ 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"; +import { SlackBridgeConfigComponent } from "./configs/bridge/slack/slack.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -125,6 +127,11 @@ const routes: Routes = [ component: AdminGitterBridgeComponent, data: {breadcrumb: "Gitter Bridge", name: "Gitter Bridge"}, }, + { + path: "slack", + component: AdminSlackBridgeComponent, + data: {breadcrumb: "Slack Bridge", name: "Slack Bridge"}, + }, ], }, { @@ -232,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 new file mode 100644 index 0000000..cebaa29 --- /dev/null +++ b/web/app/shared/models/slack.ts @@ -0,0 +1,24 @@ +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; +} + +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/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(); + } +} 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 0000000..1a95146 Binary files /dev/null and b/web/public/img/avatars/slack.png differ diff --git a/web/public/img/slack_auth_button.png b/web/public/img/slack_auth_button.png new file mode 100644 index 0000000..25cb7ba Binary files /dev/null and b/web/public/img/slack_auth_button.png differ