Merge pull request #217 from turt2live/travis/webhooks2

Webhooks bridge support
This commit is contained in:
Travis Ralston 2018-10-20 19:09:06 -06:00 committed by GitHub
commit cb19c369d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 839 additions and 40 deletions

View File

@ -54,3 +54,7 @@ The frontend is otherwise a fairly basic Angular 5 application: there's componen
kept small and generic where possible (almost always matching the Service classes in the backend). Components are more of kept small and generic where possible (almost always matching the Service classes in the backend). Components are more of
a judgement call and should be split out where it makes sense. For example, it doesn't make sense to create a component a judgement call and should be split out where it makes sense. For example, it doesn't make sense to create a component
for every instance where an `ngFor` is used because the number of components would be astronomical. for every instance where an `ngFor` is used because the number of components would be astronomical.
## Reference Material
Adding a bridge to Dimension: https://github.com/turt2live/matrix-dimension/pull/217

View File

@ -0,0 +1,106 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { AdminService } from "./AdminService";
import { Cache, CACHE_INTEGRATIONS, CACHE_TELEGRAM_BRIDGE } from "../../MemoryCache";
import { LogService } from "matrix-js-snippets";
import { ApiError } from "../ApiError";
import WebhookBridgeRecord from "../../db/models/WebhookBridgeRecord";
interface CreateWithUpstream {
upstreamId: number;
}
interface CreateSelfhosted {
provisionUrl: string;
sharedSecret: string;
}
interface BridgeResponse {
id: number;
upstreamId?: number;
provisionUrl?: string;
sharedSecret?: string;
isEnabled: boolean;
}
/**
* Administrative API for configuring Webhook bridge instances.
*/
@Path("/api/v1/dimension/admin/webhooks")
export class AdminWebhooksService {
@GET
@Path("all")
public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise<BridgeResponse[]> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const bridges = await WebhookBridgeRecord.findAll();
return Promise.all(bridges.map(async b => {
return {
id: b.id,
upstreamId: b.upstreamId,
provisionUrl: b.provisionUrl,
sharedSecret: b.sharedSecret,
isEnabled: b.isEnabled,
};
}));
}
@GET
@Path(":bridgeId")
public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise<BridgeResponse> {
await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const webhookBridge = await WebhookBridgeRecord.findByPrimary(bridgeId);
if (!webhookBridge) throw new ApiError(404, "Webhook Bridge not found");
return {
id: webhookBridge.id,
upstreamId: webhookBridge.upstreamId,
provisionUrl: webhookBridge.provisionUrl,
sharedSecret: webhookBridge.sharedSecret,
isEnabled: webhookBridge.isEnabled,
};
}
@POST
@Path(":bridgeId")
public async updateBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise<BridgeResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const bridge = await WebhookBridgeRecord.findByPrimary(bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
bridge.provisionUrl = request.provisionUrl;
bridge.sharedSecret = request.sharedSecret;
await bridge.save();
LogService.info("AdminWebhooksService", userId + " updated Webhook Bridge " + bridge.id);
Cache.for(CACHE_TELEGRAM_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<BridgeResponse> {
throw new ApiError(400, "Cannot create a webhook bridge from an upstream");
}
@POST
@Path("new/selfhosted")
public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise<BridgeResponse> {
const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken);
const bridge = await WebhookBridgeRecord.create({
provisionUrl: request.provisionUrl,
sharedSecret: request.sharedSecret,
isEnabled: true,
});
LogService.info("AdminWebhooksService", userId + " created a new Webhook Bridge with provisioning URL " + request.provisionUrl);
Cache.for(CACHE_TELEGRAM_BRIDGE).clear();
Cache.for(CACHE_INTEGRATIONS).clear();
return this.getBridge(scalarToken, bridge.id);
}
}

View File

@ -1,38 +0,0 @@
import { FormParam, HeaderParam, Path, PathParam, POST } from "typescript-rest";
import Webhook from "../../db/models/Webhook";
import { ApiError } from "../ApiError";
import * as request from "request";
import { LogService } from "matrix-js-snippets";
/**
* API for proxying webhooks to other services.
*/
@Path("/api/v1/dimension/webhooks")
export class DimensionWebhookService {
@POST
@Path("/travisci/:webhookId")
public async postTravisCiWebhook(@PathParam("webhookId") webhookId: string, @FormParam("payload") payload: string, @HeaderParam("Signature") signature: string): Promise<any> {
const webhook = await Webhook.findByPrimary(webhookId).catch(() => null);
if (!webhook) throw new ApiError(404, "Webhook not found");
if (!webhook.targetUrl) throw new ApiError(400, "Webhook not configured");
return new Promise((resolve, _reject) => {
request({
method: "POST",
url: webhook.targetUrl,
form: {payload: payload},
headers: {
"Signature": signature,
},
}, (err, res, _body) => {
if (err) {
LogService.error("DimensionWebhooksService", "Error invoking travis-ci webhook");
LogService.error("DimensionWebhooksService", res.body);
throw new ApiError(500, "Internal Server Error");
} else resolve();
});
});
}
}

View File

@ -0,0 +1,69 @@
import { DELETE, FormParam, HeaderParam, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { ScalarService } from "../scalar/ScalarService";
import { SuccessResponse, WebhookConfiguration, WebhookOptions } from "../../bridges/models/webhooks";
import { WebhooksBridge } from "../../bridges/WebhooksBridge";
import Webhook from "../../db/models/Webhook";
import { ApiError } from "../ApiError";
import { LogService } from "matrix-js-snippets";
import * as request from "request";
/**
* API for interacting with the Webhooks bridge, and for setting up proxies to other
* services.
*/
@Path("/api/v1/dimension/webhooks")
export class DimensionWebhooksService {
@POST
@Path("/travisci/:webhookId")
public async postTravisCiWebhook(@PathParam("webhookId") webhookId: string, @FormParam("payload") payload: string, @HeaderParam("Signature") signature: string): Promise<any> {
const webhook = await Webhook.findByPrimary(webhookId).catch(() => null);
if (!webhook) throw new ApiError(404, "Webhook not found");
if (!webhook.targetUrl) throw new ApiError(400, "Webhook not configured");
return new Promise((resolve, _reject) => {
request({
method: "POST",
url: webhook.targetUrl,
form: {payload: payload},
headers: {
"Signature": signature,
},
}, (err, res, _body) => {
if (err) {
LogService.error("DimensionWebhooksService", "Error invoking travis-ci webhook");
LogService.error("DimensionWebhooksService", res.body);
throw new ApiError(500, "Internal Server Error");
} else resolve();
});
});
}
@POST
@Path("room/:roomId/webhooks/new")
public async newWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, options: WebhookOptions): Promise<WebhookConfiguration> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const webhooks = new WebhooksBridge(userId);
return webhooks.createWebhook(roomId, options);
}
@POST
@Path("room/:roomId/webhooks/:hookId")
public async updateWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("hookId") hookId: string, options: WebhookOptions): Promise<WebhookConfiguration> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const webhooks = new WebhooksBridge(userId);
return webhooks.updateWebhook(roomId, hookId, options);
}
@DELETE
@Path("room/:roomId/webhooks/:hookId")
public async deleteWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("hookId") hookId: string): Promise<SuccessResponse> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const webhooks = new WebhooksBridge(userId);
return webhooks.deleteWebhook(roomId, hookId);
}
}

View File

@ -0,0 +1,99 @@
import { LogService } from "matrix-js-snippets";
import * as request from "request";
import {
ListWebhooksResponse,
SuccessResponse,
WebhookBridgeInfo,
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 getBridgeInfo(): Promise<WebhookBridgeInfo> {
const bridge = await this.getDefaultBridge();
return this.doProvisionRequest<WebhookBridgeInfo>(bridge, "GET", "/api/v1/provision/info");
}
public async getHooks(roomId: string): Promise<WebhookConfiguration[]> {
const bridge = await this.getDefaultBridge();
try {
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;
} catch (e) {
LogService.error("WebhooksBridge", e);
return [];
}
}
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,27 @@
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;
}
export interface WebhookBridgeInfo {
botUserId: string;
}

View File

@ -1,8 +1,9 @@
import { Bridge, TelegramBridgeConfiguration } from "../integrations/Bridge"; import { Bridge, TelegramBridgeConfiguration, 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 { TelegramBridge } from "../bridges/TelegramBridge"; import { TelegramBridge } from "../bridges/TelegramBridge";
import { WebhooksBridge } from "../bridges/WebhooksBridge";
export class BridgeStore { export class BridgeStore {
@ -47,6 +48,8 @@ export class BridgeStore {
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 === "telegram") { } else if (integrationType === "telegram") {
throw new Error("Telegram bridges should be modified with the dedicated API"); 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 throw new Error("Unsupported bridge"); } else throw new Error("Unsupported bridge");
} }
@ -57,6 +60,9 @@ export class BridgeStore {
} else if (record.type === "telegram") { } else if (record.type === "telegram") {
const telegram = new TelegramBridge(requestingUserId); const telegram = new TelegramBridge(requestingUserId);
return telegram.isBridgingEnabled(); return telegram.isBridgingEnabled();
} else if (record.type === "webhooks") {
const webhooks = new WebhooksBridge(requestingUserId);
return webhooks.isBridgingEnabled();
} else return true; } else return true;
} }
@ -76,6 +82,15 @@ export class BridgeStore {
portalInfo: roomConf, portalInfo: roomConf,
puppet: await telegram.getPuppetInfo(), puppet: await telegram.getPuppetInfo(),
}; };
} 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);
const info = await webhooks.getBridgeInfo();
return <WebhookBridgeConfiguration>{
webhooks: hooks,
botUserId: info.botUserId,
};
} else return {}; } else return {};
} }

View File

@ -22,6 +22,7 @@ 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 TelegramBridgeRecord from "./models/TelegramBridgeRecord"; import TelegramBridgeRecord from "./models/TelegramBridgeRecord";
import WebhookBridgeRecord from "./models/WebhookBridgeRecord";
class _DimensionStore { class _DimensionStore {
private sequelize: Sequelize; private sequelize: Sequelize;
@ -55,6 +56,7 @@ class _DimensionStore {
Sticker, Sticker,
UserStickerPack, UserStickerPack,
TelegramBridgeRecord, TelegramBridgeRecord,
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

@ -2,12 +2,15 @@ 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 { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge"; import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge";
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) {
super(bridge); super(bridge);
this.category = "bridge"; this.category = "bridge";
this.requirements = [{
if (bridge.type === "webhooks") this.requirements = [];
else this.requirements = [{
condition: "publicRoom", condition: "publicRoom",
expectedValue: true, expectedValue: true,
argument: null, // not used argument: null, // not used
@ -29,3 +32,8 @@ export interface TelegramBridgeConfiguration {
portalInfo: PortalInfo; portalInfo: PortalInfo;
puppet: PuppetInfo; puppet: PuppetInfo;
} }
export interface WebhookBridgeConfiguration {
webhooks: WebhookConfiguration[];
botUserId: string;
}

View File

@ -0,0 +1,32 @@
<div class="dialog">
<div class="dialog-header">
<h4>{{ isAdding ? "Add a new" : "Edit" }} self-hosted webhook bridge</h4>
</div>
<div class="dialog-content">
<p>Self-hosted webhook bridges must have <code>provisioning</code> enabled in the configuration.</p>
<label class="label-block">
Provisioning URL
<span class="text-muted ">The public URL for the bridge.</span>
<input type="text" class="form-control"
placeholder="https://webhooks.example.org:9000/"
[(ngModel)]="provisionUrl" [disabled]="isSaving"/>
</label>
<label class="label-block">
Shared Secret
<span class="text-muted ">The provisioning secret defined in the configuration.</span>
<input type="text" class="form-control"
placeholder="some_secret_value"
[(ngModel)]="sharedSecret" [disabled]="isSaving"/>
</label>
</div>
<div class="dialog-footer">
<button type="button" (click)="add()" title="close" class="btn btn-primary btn-sm">
<i class="far fa-save"></i> Save
</button>
<button type="button" (click)="dialog.close()" title="close" class="btn btn-secondary btn-sm">
<i class="far fa-times-circle"></i> Cancel
</button>
</div>
</div>

View File

@ -0,0 +1,58 @@
import { Component } from "@angular/core";
import { ToasterService } from "angular2-toaster";
import { DialogRef, ModalComponent } from "ngx-modialog";
import { BSModalContext } from "ngx-modialog/plugins/bootstrap";
import { AdminWebhooksApiService } from "../../../../shared/services/admin/admin-webhooks-api.service";
export class ManageSelfhostedWebhooksBridgeDialogContext extends BSModalContext {
public provisionUrl: string;
public sharedSecret: string;
public allowTgPuppets = false;
public allowMxPuppets = false;
public bridgeId: number;
}
@Component({
templateUrl: "./manage-selfhosted.component.html",
styleUrls: ["./manage-selfhosted.component.scss"],
})
export class AdminWebhooksBridgeManageSelfhostedComponent implements ModalComponent<ManageSelfhostedWebhooksBridgeDialogContext> {
public isSaving = false;
public provisionUrl: string;
public sharedSecret: string;
public bridgeId: number;
public isAdding = false;
constructor(public dialog: DialogRef<ManageSelfhostedWebhooksBridgeDialogContext>,
private webhooksApi: AdminWebhooksApiService,
private toaster: ToasterService) {
this.provisionUrl = dialog.context.provisionUrl;
this.sharedSecret = dialog.context.sharedSecret;
this.bridgeId = dialog.context.bridgeId;
this.isAdding = !this.bridgeId;
}
public add() {
this.isSaving = true;
if (this.isAdding) {
this.webhooksApi.newSelfhosted(this.provisionUrl, this.sharedSecret).then(() => {
this.toaster.pop("success", "Webhook bridge added");
this.dialog.close();
}).catch(err => {
console.error(err);
this.isSaving = false;
this.toaster.pop("error", "Failed to create Webhook bridge");
});
} else {
this.webhooksApi.updateSelfhosted(this.bridgeId, this.provisionUrl, this.sharedSecret).then(() => {
this.toaster.pop("success", "Webhook bridge updated");
this.dialog.close();
}).catch(err => {
console.error(err);
this.isSaving = false;
this.toaster.pop("error", "Failed to update Webhook bridge");
});
}
}
}

View File

@ -0,0 +1,41 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox title="Webhooks Bridge Configuration">
<div class="my-ibox-content">
<p>
<a href="https://github.com/turt2live/matrix-appservice-webhooks" target="_blank">matrix-appservice-webhooks</a>
provides Slack-compatible webhooks for Matrix, making it easy to send updates into a room.
</p>
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Name</th>
<th class="text-center" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!configurations || configurations.length === 0">
<td colspan="2"><i>No bridge configurations.</i></td>
</tr>
<tr *ngFor="let bridge of configurations trackById">
<td>
{{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }}
<span class="text-muted" style="display: inline-block;" *ngIf="!bridge.upstreamId">({{ bridge.provisionUrl }})</span>
</td>
<td class="text-center">
<span class="editButton" (click)="editBridge(bridge)">
<i class="fa fa-pencil-alt"></i>
</span>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-success btn-sm" (click)="addSelfHostedBridge()" [disabled]="configurations && configurations.length > 0">
<i class="fa fa-plus"></i> Add self-hosted bridge
</button>
</div>
</my-ibox>
</div>

View File

@ -0,0 +1,3 @@
.editButton {
cursor: pointer;
}

View File

@ -0,0 +1,70 @@
import { Component, OnInit } from "@angular/core";
import { ToasterService } from "angular2-toaster";
import { Modal, overlayConfigFactory } from "ngx-modialog";
import {
AdminWebhooksBridgeManageSelfhostedComponent,
ManageSelfhostedWebhooksBridgeDialogContext
} from "./manage-selfhosted/manage-selfhosted.component";
import { FE_WebhooksBridge } from "../../../shared/models/webhooks";
import { AdminWebhooksApiService } from "../../../shared/services/admin/admin-webhooks-api.service";
@Component({
templateUrl: "./webhooks.component.html",
styleUrls: ["./webhooks.component.scss"],
})
export class AdminWebhooksBridgeComponent implements OnInit {
public isLoading = true;
public isUpdating = false;
public configurations: FE_WebhooksBridge[] = [];
constructor(private webhooksApi: AdminWebhooksApiService,
private toaster: ToasterService,
private modal: Modal) {
}
public ngOnInit() {
this.reload().then(() => this.isLoading = false);
}
private async reload(): Promise<any> {
try {
this.configurations = await this.webhooksApi.getBridges();
} catch (err) {
console.error(err);
this.toaster.pop("error", "Error loading bridges");
}
}
public addSelfHostedBridge() {
this.modal.open(AdminWebhooksBridgeManageSelfhostedComponent, overlayConfigFactory({
isBlocking: true,
size: 'lg',
provisionUrl: '',
sharedSecret: '',
allowPuppets: false,
}, ManageSelfhostedWebhooksBridgeDialogContext)).result.then(() => {
this.reload().catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to get an update Webhooks bridge list");
});
});
}
public editBridge(bridge: FE_WebhooksBridge) {
this.modal.open(AdminWebhooksBridgeManageSelfhostedComponent, overlayConfigFactory({
isBlocking: true,
size: 'lg',
provisionUrl: bridge.provisionUrl,
sharedSecret: bridge.sharedSecret,
bridgeId: bridge.id,
}, ManageSelfhostedWebhooksBridgeDialogContext)).result.then(() => {
this.reload().catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to get an update Webhooks bridge list");
});
});
}
}

View File

@ -85,6 +85,11 @@ import { TelegramApiService } from "./shared/services/integrations/telegram-api.
import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component";
import { TelegramAskUnbridgeComponent } from "./configs/bridge/telegram/ask-unbridge/ask-unbridge.component"; import { TelegramAskUnbridgeComponent } from "./configs/bridge/telegram/ask-unbridge/ask-unbridge.component";
import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component"; import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component";
import { AdminWebhooksBridgeManageSelfhostedComponent } from "./admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component";
import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks.component";
import { AdminWebhooksApiService } from "./shared/services/admin/admin-webhooks-api.service";
import { WebhooksApiService } from "./shared/services/integrations/webhooks-api.service";
import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhooks.bridge.component";
@NgModule({ @NgModule({
imports: [ imports: [
@ -157,6 +162,9 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno
TelegramBridgeConfigComponent, TelegramBridgeConfigComponent,
TelegramAskUnbridgeComponent, TelegramAskUnbridgeComponent,
TelegramCannotUnbridgeComponent, TelegramCannotUnbridgeComponent,
AdminWebhooksBridgeManageSelfhostedComponent,
AdminWebhooksBridgeComponent,
WebhooksBridgeConfigComponent,
// Vendor // Vendor
], ],
@ -178,6 +186,8 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno
StickerApiService, StickerApiService,
AdminTelegramApiService, AdminTelegramApiService,
TelegramApiService, TelegramApiService,
AdminWebhooksApiService,
WebhooksApiService,
{provide: Window, useValue: window}, {provide: Window, useValue: window},
// Vendor // Vendor
@ -198,6 +208,7 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno
AdminTelegramBridgeManageSelfhostedComponent, AdminTelegramBridgeManageSelfhostedComponent,
TelegramAskUnbridgeComponent, TelegramAskUnbridgeComponent,
TelegramCannotUnbridgeComponent, TelegramCannotUnbridgeComponent,
AdminWebhooksBridgeManageSelfhostedComponent,
] ]
}) })
export class AppModule { export class AppModule {

View File

@ -29,6 +29,8 @@ import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.co
import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-picker/sticker-picker.component"; import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-picker/sticker-picker.component";
import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram.component"; import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram.component";
import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component";
import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks.component";
import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhooks.bridge.component";
const routes: Routes = [ const routes: Routes = [
{path: "", component: HomeComponent}, {path: "", component: HomeComponent},
@ -94,6 +96,11 @@ const routes: Routes = [
component: AdminTelegramBridgeComponent, component: AdminTelegramBridgeComponent,
data: {breadcrumb: "Telegram Bridge", name: "Telegram Bridge"}, data: {breadcrumb: "Telegram Bridge", name: "Telegram Bridge"},
}, },
{
path: "webhooks",
component: AdminWebhooksBridgeComponent,
data: {breadcrumb: "Webhook Bridge", name: "Webhook Bridge"},
},
], ],
}, },
{ {
@ -176,6 +183,11 @@ const routes: Routes = [
component: TelegramBridgeConfigComponent, component: TelegramBridgeConfigComponent,
data: {breadcrumb: "Telegram Bridge Configuration", name: "Telegram Bridge Configuration"}, data: {breadcrumb: "Telegram Bridge Configuration", name: "Telegram Bridge Configuration"},
}, },
{
path: "webhooks",
component: WebhooksBridgeConfigComponent,
data: {breadcrumb: "Webhook Bridge Configuration", name: "Webhook Bridge Configuration"},
},
], ],
}, },
{ {

View File

@ -0,0 +1,55 @@
<my-bridge-config [bridgeComponent]="this">
<ng-template #bridgeParamsTemplate>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
Add a new webhook
</h5>
<div class="my-ibox-content">
<label class="label-block">
Webhook Name
<input title="webhook name" type="text" class="form-control form-control-sm" [(ngModel)]="webhookName" [disabled]="isBusy">
</label>
<div style="margin-top: 25px">
<button type="button" class="btn btn-sm btn-primary" [disabled]="isBusy" (click)="newHook()">
Create
</button>
</div>
</div>
</my-ibox>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
Webhooks
</h5>
<div class="my-ibox-content">
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>URL</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="newConfig.webhooks.length === 0">
<td colspan="4">No webhooks</td>
</tr>
<tr *ngFor="let hook of newConfig.webhooks">
<td *ngIf="hook.label">{{ hook.label }}</td>
<td *ngIf="!hook.label"><i>No name</i></td>
<td>{{ hook.type }}</td>
<td class="webhook-url"><a [href]="hook.url" target="_blank">{{ hook.url }}</a></td>
<td class="actions-col">
<button type="button" class="btn btn-sm btn-outline-danger"
[disabled]="isBusy"
(click)="removeHook(hook)">
<i class="far fa-trash-alt"></i> Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</my-ibox>
</ng-template>
</my-bridge-config>

View File

@ -0,0 +1,3 @@
.webhook-url {
word-break: break-word;
}

View File

@ -0,0 +1,64 @@
import { Component } from "@angular/core";
import { BridgeComponent } from "../bridge.component";
import { FE_Webhook } from "../../../shared/models/webhooks";
import { WebhooksApiService } from "../../../shared/services/integrations/webhooks-api.service";
import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service";
interface WebhooksConfig {
webhooks: FE_Webhook[];
botUserId: string;
}
@Component({
templateUrl: "webhooks.bridge.component.html",
styleUrls: ["webhooks.bridge.component.scss"],
})
export class WebhooksBridgeConfigComponent extends BridgeComponent<WebhooksConfig> {
public webhookName: string;
public isBusy = false;
constructor(private webhooks: WebhooksApiService, private scalar: ScalarClientApiService) {
super("webhooks");
}
public async newHook() {
this.isBusy = true;
try {
await this.scalar.inviteUser(this.roomId, this.newConfig.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.webhooks.createWebhook(this.roomId, {label: this.webhookName}).then(hook => {
this.newConfig.webhooks.push(hook);
this.isBusy = false;
this.webhookName = "";
this.toaster.pop("success", "Webhook created");
}).catch(err => {
console.error(err);
this.isBusy = false;
this.toaster.pop("error", "Error creating webhook");
});
}
public removeHook(hook: FE_Webhook) {
this.isBusy = true;
this.webhooks.deleteWebhook(this.roomId, hook.id).then(() => {
const idx = this.newConfig.webhooks.indexOf(hook);
if (idx !== -1) this.newConfig.webhooks.splice(idx, 1);
this.isBusy = false;
this.toaster.pop("success", "Webhook deleted");
}).catch(err => {
console.error(err);
this.isBusy = false;
this.toaster.pop("error", "Error deleting webhook");
});
}
}

View File

@ -0,0 +1,20 @@
export interface FE_WebhooksBridge {
id: number;
upstreamId?: number;
provisionUrl?: string;
sharedSecret?: string;
isEnabled: boolean;
}
export interface FE_Webhook {
id: string;
label: string;
url: string;
userId: string;
roomId: string;
type: "incoming";
}
export interface FE_WebhookOptions {
label: string;
}

View File

@ -18,6 +18,7 @@ export class IntegrationsRegistry {
"bridge": { "bridge": {
"irc": {}, "irc": {},
"telegram": {}, "telegram": {},
"webhooks": {},
}, },
"widget": { "widget": {
"custom": { "custom": {

View File

@ -0,0 +1,38 @@
import { Injectable } from "@angular/core";
import { Http } from "@angular/http";
import { AuthedApi } from "../authed-api";
import { FE_Upstream } from "../../models/admin-responses";
import { FE_WebhooksBridge } from "../../models/webhooks";
@Injectable()
export class AdminWebhooksApiService extends AuthedApi {
constructor(http: Http) {
super(http);
}
public getBridges(): Promise<FE_WebhooksBridge[]> {
return this.authedGet("/api/v1/dimension/admin/webhooks/all").map(r => r.json()).toPromise();
}
public getBridge(bridgeId: number): Promise<FE_WebhooksBridge> {
return this.authedGet("/api/v1/dimension/admin/webhooks/" + bridgeId).map(r => r.json()).toPromise();
}
public newFromUpstream(upstream: FE_Upstream): Promise<FE_WebhooksBridge> {
return this.authedPost("/api/v1/dimension/admin/webhooks/new/upstream", {upstreamId: upstream.id}).map(r => r.json()).toPromise();
}
public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise<FE_WebhooksBridge> {
return this.authedPost("/api/v1/dimension/admin/webhooks/new/selfhosted", {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).map(r => r.json()).toPromise();
}
public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise<FE_WebhooksBridge> {
return this.authedPost("/api/v1/dimension/admin/webhooks/" + bridgeId, {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).map(r => r.json()).toPromise();
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from "@angular/core";
import { Http } from "@angular/http";
import { AuthedApi } from "../authed-api";
import { FE_Webhook, FE_WebhookOptions } from "../../models/webhooks";
@Injectable()
export class WebhooksApiService extends AuthedApi {
constructor(http: Http) {
super(http);
}
public createWebhook(roomId: string, options: FE_WebhookOptions): Promise<FE_Webhook> {
return this.authedPost("/api/v1/dimension/webhooks/room/" + roomId + "/webhooks/new", options).map(r => r.json()).toPromise();
}
public updateWebhook(roomId: string, hookId: string, options: FE_WebhookOptions): Promise<FE_Webhook> {
return this.authedPost("/api/v1/dimension/webhooks/room/" + roomId + "/webhooks/" + hookId, options).map(r => r.json()).toPromise();
}
public deleteWebhook(roomId: string, hookId: string): Promise<any> {
return this.authedDelete("/api/v1/dimension/webhooks/room/" + roomId + "/webhooks/" + hookId).map(r => r.json()).toPromise();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB