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.
This commit is contained in:
Travis Ralston 2018-10-24 20:29:39 -06:00
parent b0abf7d38e
commit 83ad75984f
8 changed files with 424 additions and 2 deletions

View file

@ -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)**
```
{

284
src/bridges/SlackBridge.ts Normal file
View file

@ -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<SlackBridgeRecord> {
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<boolean> {
const bridges = await SlackBridgeRecord.findAll({where: {isEnabled: true}});
return !!bridges;
}
public async getBridgeInfo(): Promise<SlackBridgeInfo> {
const bridge = await this.getDefaultBridge();
if (bridge.upstreamId) {
const info = await this.doUpstreamRequest<ModularSlackResponse<GetBotUserIdResponse>>(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<GetBotUserIdResponse>(bridge, "POST", "/_matrix/provision/getbotid");
return {botUserId: info.bot_user_id};
}
}
public async getLink(roomId: string): Promise<BridgedChannel> {
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<ModularSlackResponse<BridgedChannelResponse>>(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<BridgedChannelResponse>(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<any> {
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<any> {
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<any> {
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<SlackChannel[]> {
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<ModularSlackResponse<ChannelsResponse>>(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<ChannelsResponse>(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<SlackTeam[]> {
const bridge = await this.getDefaultBridge();
const requestBody = {
user_id: this.requestingUserId,
};
try {
if (bridge.upstreamId) {
delete requestBody["user_id"];
const response = await this.doUpstreamRequest<ModularSlackResponse<TeamsResponse>>(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<TeamsResponse>(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<T>(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
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<T>((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<T>(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise<T> {
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<T>((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);
}
});
});
}
}

View file

@ -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;
}

View file

@ -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,
]);
}

View file

@ -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"));
}
}

View file

@ -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",
}));
}
}

View file

@ -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<SlackBridgeRecord> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
@ForeignKey(() => Upstream)
upstreamId?: number;
@AllowNull
@Column
provisionUrl?: string;
@Column
isEnabled: boolean;
}

View file

@ -15,4 +15,11 @@ export interface ModularGitterResponse<T> {
rid: string;
response: T;
}[];
}
export interface ModularSlackResponse<T> {
replies: {
rid: string;
response: T;
}[];
}