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(); + } +}