diff --git a/src/bridges/WebhooksBridge.ts b/src/bridges/WebhooksBridge.ts new file mode 100644 index 0000000..827fd10 --- /dev/null +++ b/src/bridges/WebhooksBridge.ts @@ -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 { + 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 { + const bridges = await WebhookBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges; + } + + public async getHooks(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const response = await this.doProvisionRequest(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 { + const bridge = await this.getDefaultBridge(); + return this.doProvisionRequest(bridge, "PUT", `/api/v1/provision/${roomId}/hook`, null, options); + } + + public async updateWebhook(roomId: string, hookId: string, options: WebhookOptions): Promise { + const bridge = await this.getDefaultBridge(); + return this.doProvisionRequest(bridge, "PUT", `/api/v1/provision/${roomId}/hook/${hookId}`, null, options); + } + + public async deleteWebhook(roomId: string, hookId: string): Promise { + const bridge = await this.getDefaultBridge(); + return this.doProvisionRequest(bridge, "DELETE", `/api/v1/provision/${roomId}/hook/${hookId}`); + } + + private async doProvisionRequest(bridge: WebhookBridgeRecord, 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("WebhooksBridge", "Doing provision Webhooks Bridge request: " + url); + + if (!qs) qs = {}; + if (!qs["userId"]) qs["userId"] = this.requestingUserId; + qs["token"] = bridge.sharedSecret; + + return new Promise((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); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/bridges/models/webhooks.ts b/src/bridges/models/webhooks.ts new file mode 100644 index 0000000..2c76f91 --- /dev/null +++ b/src/bridges/models/webhooks.ts @@ -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; +} \ No newline at end of file diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index dac1c21..68a1950 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,7 +1,8 @@ -import { Bridge } from "../integrations/Bridge"; +import { Bridge, WebhookBridgeConfiguration } from "../integrations/Bridge"; import BridgeRecord from "./models/BridgeRecord"; import { IrcBridge } from "../bridges/IrcBridge"; import { LogService } from "matrix-js-snippets"; +import { WebhooksBridge } from "../bridges/WebhooksBridge"; export class BridgeStore { @@ -44,6 +45,8 @@ export class BridgeStore { if (integrationType === "irc") { 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"); } @@ -51,6 +54,9 @@ export class BridgeStore { if (record.type === "irc") { const irc = new IrcBridge(requestingUserId); return irc.hasNetworks(); + } else if (record.type === "webhooks") { + const webhooks = new WebhooksBridge(requestingUserId); + return webhooks.isBridgingEnabled(); } else return true; } @@ -59,6 +65,13 @@ export class BridgeStore { if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs const irc = new IrcBridge(requestingUserId); 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 { + webhooks: hooks, + }; } else return {}; } diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 0ca67fe..e5ea13c 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -21,6 +21,7 @@ import IrcBridgeNetwork from "./models/IrcBridgeNetwork"; import StickerPack from "./models/StickerPack"; import Sticker from "./models/Sticker"; import UserStickerPack from "./models/UserStickerPack"; +import WebhookBridgeRecord from "./models/WebhookBridgeRecord"; class _DimensionStore { private sequelize: Sequelize; @@ -53,6 +54,7 @@ class _DimensionStore { StickerPack, Sticker, UserStickerPack, + WebhookBridgeRecord, ]); } diff --git a/src/db/migrations/20181019205145-AddWebhooksBridge.ts b/src/db/migrations/20181019205145-AddWebhooksBridge.ts new file mode 100644 index 0000000..ce04ad4 --- /dev/null +++ b/src/db/migrations/20181019205145-AddWebhooksBridge.ts @@ -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")); + } +} \ No newline at end of file diff --git a/src/db/migrations/20181019205245-AddWebhookBridgeRecord.ts b/src/db/migrations/20181019205245-AddWebhookBridgeRecord.ts new file mode 100644 index 0000000..6aead4c --- /dev/null +++ b/src/db/migrations/20181019205245-AddWebhookBridgeRecord.ts @@ -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", + })); + } +} \ No newline at end of file diff --git a/src/db/models/WebhookBridgeRecord.ts b/src/db/models/WebhookBridgeRecord.ts new file mode 100644 index 0000000..2b0c0d7 --- /dev/null +++ b/src/db/models/WebhookBridgeRecord.ts @@ -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 { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @AllowNull + @Column + sharedSecret?: string; + + @Column + isEnabled: boolean; +} \ No newline at end of file diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index fef2643..182488b 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -1,6 +1,7 @@ import { Integration } from "./Integration"; import BridgeRecord from "../db/models/BridgeRecord"; import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; +import { WebhookConfiguration } from "../bridges/models/webhooks"; export class Bridge extends Integration { constructor(bridge: BridgeRecord, public config: any) { @@ -20,4 +21,8 @@ export class Bridge extends Integration { export interface IrcBridgeConfiguration { availableNetworks: AvailableNetworks; links: LinkedChannels; +} + +export interface WebhookBridgeConfiguration { + webhooks: WebhookConfiguration[]; } \ No newline at end of file