Add backend support for the webhooks bridge

This commit is contained in:
Travis Ralston 2018-10-19 21:13:57 -06:00
parent 235d8051fe
commit 93b532de44
8 changed files with 208 additions and 1 deletions

View File

@ -0,0 +1,88 @@
import { LogService } from "matrix-js-snippets";
import * as request from "request";
import {
ListWebhooksResponse,
SuccessResponse,
WebhookConfiguration,
WebhookOptions,
WebhookResponse
} from "./models/webhooks";
import WebhookBridgeRecord from "../db/models/WebhookBridgeRecord";
export class WebhooksBridge {
constructor(private requestingUserId: string) {
}
private async getDefaultBridge(): Promise<WebhookBridgeRecord> {
const bridges = await WebhookBridgeRecord.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 WebhookBridgeRecord.findAll({where: {isEnabled: true}});
return !!bridges;
}
public async getHooks(roomId: string): Promise<WebhookConfiguration[]> {
const bridge = await this.getDefaultBridge();
const response = await this.doProvisionRequest<ListWebhooksResponse>(bridge, "GET", `/api/v1/provision/${roomId}/hooks`);
if (!response.success) throw new Error("Failed to get webhooks");
return response.results;
}
public async createWebhook(roomId: string, options: WebhookOptions): Promise<WebhookConfiguration> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<WebhookResponse>(bridge, "PUT", `/api/v1/provision/${roomId}/hook`, null, options);
}
public async updateWebhook(roomId: string, hookId: string, options: WebhookOptions): Promise<WebhookConfiguration> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<WebhookResponse>(bridge, "PUT", `/api/v1/provision/${roomId}/hook/${hookId}`, null, options);
}
public async deleteWebhook(roomId: string, hookId: string): Promise<any> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<SuccessResponse>(bridge, "DELETE", `/api/v1/provision/${roomId}/hook/${hookId}`);
}
private async doProvisionRequest<T>(bridge: WebhookBridgeRecord, 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("WebhooksBridge", "Doing provision Webhooks Bridge request: " + url);
if (!qs) qs = {};
if (!qs["userId"]) qs["userId"] = this.requestingUserId;
qs["token"] = bridge.sharedSecret;
return new Promise<T>((resolve, reject) => {
request({
method: method,
url: url,
qs: qs,
json: body,
}, (err, res, _body) => {
if (err) {
LogService.error("WebhooksBridge", "Error calling" + url);
LogService.error("WebhooksBridge", err);
reject(err);
} else if (!res) {
LogService.error("WebhooksBridge", "There is no response for " + url);
reject(new Error("No response provided - is the service online?"));
} else if (res.statusCode !== 200) {
LogService.error("WebhooksBridge", "Got status code " + res.statusCode + " when calling " + url);
LogService.error("WebhooksBridge", res.body);
reject(new Error("Request failed"));
} else {
if (typeof(res.body) === "string") res.body = JSON.parse(res.body);
resolve(res.body);
}
});
});
}
}

View File

@ -0,0 +1,23 @@
export interface WebhookConfiguration {
id: string;
label: string;
url: string;
userId: string;
roomId: string;
type: "incoming";
}
export interface ListWebhooksResponse extends SuccessResponse {
results: WebhookConfiguration[];
}
export interface WebhookResponse extends WebhookConfiguration, SuccessResponse {
}
export interface WebhookOptions {
label: string;
}
export interface SuccessResponse {
success: boolean;
}

View File

@ -1,7 +1,8 @@
import { Bridge } from "../integrations/Bridge"; import { Bridge, WebhookBridgeConfiguration } from "../integrations/Bridge";
import BridgeRecord from "./models/BridgeRecord"; import BridgeRecord from "./models/BridgeRecord";
import { IrcBridge } from "../bridges/IrcBridge"; import { IrcBridge } from "../bridges/IrcBridge";
import { LogService } from "matrix-js-snippets"; import { LogService } from "matrix-js-snippets";
import { WebhooksBridge } from "../bridges/WebhooksBridge";
export class BridgeStore { export class BridgeStore {
@ -44,6 +45,8 @@ export class BridgeStore {
if (integrationType === "irc") { if (integrationType === "irc") {
throw new Error("IRC Bridges should be modified with the dedicated API"); throw new Error("IRC Bridges should be modified with the dedicated API");
} else if (integrationType === "webhooks") {
throw new Error("Webhooks should be modified with the dedicated API");
} else throw new Error("Unsupported bridge"); } else throw new Error("Unsupported bridge");
} }
@ -51,6 +54,9 @@ export class BridgeStore {
if (record.type === "irc") { if (record.type === "irc") {
const irc = new IrcBridge(requestingUserId); const irc = new IrcBridge(requestingUserId);
return irc.hasNetworks(); return irc.hasNetworks();
} else if (record.type === "webhooks") {
const webhooks = new WebhooksBridge(requestingUserId);
return webhooks.isBridgingEnabled();
} else return true; } else return true;
} }
@ -59,6 +65,13 @@ export class BridgeStore {
if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs
const irc = new IrcBridge(requestingUserId); const irc = new IrcBridge(requestingUserId);
return irc.getRoomConfiguration(inRoomId); return irc.getRoomConfiguration(inRoomId);
} else if (record.type === "webhooks") {
if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs
const webhooks = new WebhooksBridge(requestingUserId);
const hooks = await webhooks.getHooks(inRoomId);
return <WebhookBridgeConfiguration>{
webhooks: hooks,
};
} else return {}; } else return {};
} }

View File

@ -21,6 +21,7 @@ import IrcBridgeNetwork from "./models/IrcBridgeNetwork";
import StickerPack from "./models/StickerPack"; import StickerPack from "./models/StickerPack";
import Sticker from "./models/Sticker"; import Sticker from "./models/Sticker";
import UserStickerPack from "./models/UserStickerPack"; import UserStickerPack from "./models/UserStickerPack";
import WebhookBridgeRecord from "./models/WebhookBridgeRecord";
class _DimensionStore { class _DimensionStore {
private sequelize: Sequelize; private sequelize: Sequelize;
@ -53,6 +54,7 @@ class _DimensionStore {
StickerPack, StickerPack,
Sticker, Sticker,
UserStickerPack, UserStickerPack,
WebhookBridgeRecord,
]); ]);
} }

View File

@ -0,0 +1,23 @@
import { QueryInterface } from "sequelize";
import { DataType } from "sequelize-typescript";
export default {
up: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.createTable("dimension_webhook_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},
"sharedSecret": {type: DataType.STRING, allowNull: true},
"isEnabled": {type: DataType.BOOLEAN, allowNull: false},
}));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.dropTable("dimension_webhook_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: "webhooks",
name: "Webhook Bridge",
avatarUrl: "/img/avatars/webhooks.png",
isEnabled: true,
isPublic: true,
description: "Slack-compatible webhooks for your room",
},
]));
},
down: (queryInterface: QueryInterface) => {
return Promise.resolve()
.then(() => queryInterface.bulkDelete("dimension_bridges", {
type: "webhooks",
}));
}
}

View File

@ -0,0 +1,30 @@
import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript";
import Upstream from "./Upstream";
@Table({
tableName: "dimension_webhook_bridges",
underscoredAll: false,
timestamps: false,
})
export default class WebhookBridgeRecord extends Model<WebhookBridgeRecord> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@AllowNull
@Column
@ForeignKey(() => Upstream)
upstreamId?: number;
@AllowNull
@Column
provisionUrl?: string;
@AllowNull
@Column
sharedSecret?: string;
@Column
isEnabled: boolean;
}

View File

@ -1,6 +1,7 @@
import { Integration } from "./Integration"; import { Integration } from "./Integration";
import BridgeRecord from "../db/models/BridgeRecord"; import BridgeRecord from "../db/models/BridgeRecord";
import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge";
import { WebhookConfiguration } from "../bridges/models/webhooks";
export class Bridge extends Integration { export class Bridge extends Integration {
constructor(bridge: BridgeRecord, public config: any) { constructor(bridge: BridgeRecord, public config: any) {
@ -21,3 +22,7 @@ export interface IrcBridgeConfiguration {
availableNetworks: AvailableNetworks; availableNetworks: AvailableNetworks;
links: LinkedChannels; links: LinkedChannels;
} }
export interface WebhookBridgeConfiguration {
webhooks: WebhookConfiguration[];
}