From 18597db5407e1b30e3a52cf9b4936551305ae0d2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 26 Mar 2018 21:48:44 -0600 Subject: [PATCH] Add self-service configuration for the RSS bot Fixes #14 --- .../dimension/DimensionIntegrationsService.ts | 16 ++- src/db/DimensionStore.ts | 2 + src/db/NebStore.ts | 15 +- .../20180326185545-AddNebIntegrationConfig.ts | 22 +++ src/db/models/NebIntegrationConfig.ts | 24 ++++ src/integrations/ComplexBot.ts | 10 +- src/models/ModularResponses.ts | 1 + src/neb/NebClient.ts | 1 + src/neb/NebProxy.ts | 132 +++++++++++++++++- .../complex-bot/complex-bot.component.ts | 20 ++- .../config-screen.complex-bot.component.html | 17 +-- .../rss/rss.complex-bot.component.html | 55 +++++++- .../rss/rss.complex-bot.component.scss | 4 + .../rss/rss.complex-bot.component.ts | 50 ++++++- .../integrations/integrations-api.service.ts | 4 + 15 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 src/db/migrations/20180326185545-AddNebIntegrationConfig.ts create mode 100644 src/db/models/NebIntegrationConfig.ts diff --git a/src/api/dimension/DimensionIntegrationsService.ts b/src/api/dimension/DimensionIntegrationsService.ts index 102ce36..0589360 100644 --- a/src/api/dimension/DimensionIntegrationsService.ts +++ b/src/api/dimension/DimensionIntegrationsService.ts @@ -1,4 +1,4 @@ -import { DELETE, GET, Path, PathParam, QueryParam } from "typescript-rest"; +import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; import { ScalarService } from "../scalar/ScalarService"; import { Widget } from "../../integrations/Widget"; import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache"; @@ -67,6 +67,18 @@ export class DimensionIntegrationsService { else throw new ApiError(400, "Unrecognized category"); } + @POST + @Path("room/:roomId/integrations/:category/:type/config") + public async setIntegrationConfigurationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string, newConfig: any): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + if (category === "complex-bot") await NebStore.setComplexBotConfig(userId, integrationType, roomId, newConfig); + else throw new ApiError(400, "Unrecognized category"); + + Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate + return {}; // 200 OK + } + @DELETE @Path("room/:roomId/integrations/:category/:type") public async removeIntegrationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string): Promise { @@ -74,8 +86,10 @@ export class DimensionIntegrationsService { if (category === "widget") throw new ApiError(400, "Widgets should be removed client-side"); else if (category === "bot") await NebStore.removeSimpleBot(integrationType, roomId, userId); + else if (category === "complex-bot") throw new ApiError(400, "Complex bots should be removed automatically"); else throw new ApiError(400, "Unrecognized category"); + Cache.for(CACHE_INTEGRATIONS).clear(); // TODO: Improve which cache we invalidate return {}; // 200 OK } diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 6a37bc3..bb958c4 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -13,6 +13,7 @@ import NebConfiguration from "./models/NebConfiguration"; import NebIntegration from "./models/NebIntegration"; import NebBotUser from "./models/NebBotUser"; import NebNotificationUser from "./models/NebNotificationUser"; +import NebIntegrationConfig from "./models/NebIntegrationConfig"; class _DimensionStore { private sequelize: Sequelize; @@ -37,6 +38,7 @@ class _DimensionStore { NebIntegration, NebBotUser, NebNotificationUser, + NebIntegrationConfig, ]); } diff --git a/src/db/NebStore.ts b/src/db/NebStore.ts index aafabe9..45a2fe0 100644 --- a/src/db/NebStore.ts +++ b/src/db/NebStore.ts @@ -137,13 +137,22 @@ export class NebStore { const rawIntegrations = await NebStore.listEnabledNebComplexBots(); return Promise.all(rawIntegrations.map(async i => { const proxy = new NebProxy(i.neb, requestingUserId); - const notifUserId = await proxy.getNotificationUserId(i.integration, roomId, requestingUserId); + const notifUserId = await proxy.getNotificationUserId(i.integration, roomId); const botUserId = null; // TODO: For github - // TODO: Get configuration - return new ComplexBot(i.integration, notifUserId, botUserId); + const botConfig = await proxy.getServiceConfiguration(i.integration, roomId); + return new ComplexBot(i.integration, notifUserId, botUserId, botConfig); })); } + public static async setComplexBotConfig(requestingUserId: string, type: string, roomId: string, newConfig: any): Promise { + const rawIntegrations = await NebStore.listEnabledNebComplexBots(); + const integration = rawIntegrations.find(i => i.integration.type === type); + if (!integration) throw new Error("Integration not found"); + + const proxy = new NebProxy(integration.neb, requestingUserId); + return proxy.setServiceConfiguration(integration.integration, roomId, newConfig); + } + public static async removeSimpleBot(type: string, roomId: string, requestingUserId: string): Promise { const rawIntegrations = await NebStore.listEnabledNebSimpleBots(); const integration = rawIntegrations.find(i => i.integration.type === type); diff --git a/src/db/migrations/20180326185545-AddNebIntegrationConfig.ts b/src/db/migrations/20180326185545-AddNebIntegrationConfig.ts new file mode 100644 index 0000000..e2f6a86 --- /dev/null +++ b/src/db/migrations/20180326185545-AddNebIntegrationConfig.ts @@ -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_neb_integration_config", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "integrationId": { + type: DataType.INTEGER, allowNull: false, + references: {model: "dimension_neb_integrations", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "roomId": {type: DataType.STRING, allowNull: false}, + "jsonContent": {type: DataType.STRING, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_neb_integration_config")); + } +} \ No newline at end of file diff --git a/src/db/models/NebIntegrationConfig.ts b/src/db/models/NebIntegrationConfig.ts new file mode 100644 index 0000000..dc6b6c9 --- /dev/null +++ b/src/db/models/NebIntegrationConfig.ts @@ -0,0 +1,24 @@ +import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import NebIntegration from "./NebIntegration"; + +@Table({ + tableName: "dimension_neb_integration_config", + underscoredAll: false, + timestamps: false, +}) +export default class NebIntegrationConfig extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column + @ForeignKey(() => NebIntegration) + integrationId: string; + + @Column + roomId: string; + + @Column + jsonContent: string; +} \ No newline at end of file diff --git a/src/integrations/ComplexBot.ts b/src/integrations/ComplexBot.ts index 686b48e..4530ec7 100644 --- a/src/integrations/ComplexBot.ts +++ b/src/integrations/ComplexBot.ts @@ -2,7 +2,7 @@ import { Integration } from "./Integration"; import NebIntegration from "../db/models/NebIntegration"; export class ComplexBot extends Integration { - constructor(bot: NebIntegration, public notificationUserId: string, public botUserId?: string) { + constructor(bot: NebIntegration, public notificationUserId: string, public botUserId: string, public config: any) { super(bot); this.category = "complex-bot"; this.requirements = []; @@ -10,4 +10,12 @@ export class ComplexBot extends Integration { // Notification bots are technically supported in e2e rooms this.isEncryptionSupported = true; } +} + +export interface RssBotConfiguration { + feeds: { + [url: string]: { + addedByUserId: string; + }; + }; } \ No newline at end of file diff --git a/src/models/ModularResponses.ts b/src/models/ModularResponses.ts index 01c0720..8d18f22 100644 --- a/src/models/ModularResponses.ts +++ b/src/models/ModularResponses.ts @@ -1,3 +1,4 @@ export interface ModularIntegrationInfoResponse { bot_user_id: string; + integrations?: any[]; } \ No newline at end of file diff --git a/src/neb/NebClient.ts b/src/neb/NebClient.ts index 18208a0..af8ae51 100644 --- a/src/neb/NebClient.ts +++ b/src/neb/NebClient.ts @@ -73,6 +73,7 @@ export class NebClient { reject(err); } else if (res.statusCode !== 200) { LogService.error("NebClient", "Got status code " + res.statusCode + " while performing request"); + LogService.error("NebClient", res.body); reject(new Error("Request error")); } else { resolve(res.body); diff --git a/src/neb/NebProxy.ts b/src/neb/NebProxy.ts index b83da62..f5e9137 100644 --- a/src/neb/NebProxy.ts +++ b/src/neb/NebProxy.ts @@ -9,6 +9,8 @@ import { NebClient } from "./NebClient"; import { ModularIntegrationInfoResponse } from "../models/ModularResponses"; import { AppserviceStore } from "../db/AppserviceStore"; import { MatrixAppserviceClient } from "../matrix/MatrixAppserviceClient"; +import NebIntegrationConfig from "../db/models/NebIntegrationConfig"; +import { RssBotConfiguration } from "../integrations/ComplexBot"; export class NebProxy { constructor(private neb: NebConfig, private requestingUserId: string) { @@ -31,7 +33,7 @@ export class NebProxy { } } - public async getNotificationUserId(integration: NebIntegration, inRoomId: string, forUserId: string): Promise { + public async getNotificationUserId(integration: NebIntegration, inRoomId: string): Promise { if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy"); if (this.neb.upstreamId) { @@ -45,13 +47,133 @@ export class NebProxy { return null; } } else { - return (await NebStore.getOrCreateNotificationUser(this.neb.id, integration.type, forUserId)).appserviceUserId; + return (await NebStore.getOrCreateNotificationUser(this.neb.id, integration.type, this.requestingUserId)).appserviceUserId; } } - // public async getComplexBotConfiguration(integration: NebIntegration, roomId: string): Promise { - // - // } + public async getServiceConfiguration(integration: NebIntegration, inRoomId: string): Promise { + if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy"); + + if (this.neb.upstreamId) { + // TODO: Verify + try { + const response = await this.doUpstreamRequest("/integrations/" + NebClient.getNebType(integration.type), { + room_id: inRoomId, + }); + + if (integration.type === "rss") return this.parseUpstreamRssConfiguration(response.integrations); + else return {}; + } catch (err) { + LogService.error("NebProxy", err); + return {}; + } + } else { + const serviceConfig = await NebIntegrationConfig.findOne({ + where: { + integrationId: integration.id, + roomId: inRoomId, + }, + }); + return serviceConfig ? JSON.parse(serviceConfig.jsonContent) : {}; + } + } + + public async setServiceConfiguration(integration: NebIntegration, inRoomId: string, newConfig: any): Promise { + if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy"); + + if (!this.neb.upstreamId) { + const serviceConfig = await NebIntegrationConfig.findOne({ + where: { + integrationId: integration.id, + roomId: inRoomId, + }, + }); + if (serviceConfig) { + serviceConfig.jsonContent = JSON.stringify(newConfig); + await serviceConfig.save(); + } else { + await NebIntegrationConfig.create({ + integrationId: integration.id, + roomId: inRoomId, + jsonContent: JSON.stringify(newConfig), + }); + } + } + + if (integration.type === "rss") await this.updateRssConfiguration(inRoomId, newConfig); + else throw new Error("Cannot update go-neb: unrecognized type"); + } + + private parseUpstreamRssConfiguration(integrations: any[]): any { + if (!integrations) return {}; + + const result: RssBotConfiguration = {feeds: {}}; + for (const integration of integrations) { + const userId = integration.user_id; + const feeds = integration.config ? integration.config.feeds : {}; + if (!userId || !feeds) continue; + + const urls = Object.keys(feeds); + urls.forEach(u => result.feeds[u] = {addedByUserId: userId}); + } + + return result; + } + + private async updateRssConfiguration(roomId: string, newOpts: RssBotConfiguration): Promise { + const feedUrls = Object.keys(newOpts.feeds).filter(f => newOpts.feeds[f].addedByUserId === this.requestingUserId); + const newConfig = {feeds: {}}; + let currentConfig = {feeds: {}}; + + if (this.neb.upstreamId) { + const response = await this.doUpstreamRequest("/integrations/rssbot", {room_id: roomId}); + currentConfig = await this.parseUpstreamRssConfiguration(response.integrations); + } else { + const client = new NebClient(this.neb); + const notifUser = await NebStore.getOrCreateNotificationUser(this.neb.id, "rss", this.requestingUserId); + currentConfig = await client.getServiceConfig(notifUser.serviceId); + + if (feedUrls.length === 0) { + const client = new MatrixAppserviceClient(await AppserviceStore.getAppservice(this.neb.appserviceId)); + await client.leaveRoom(notifUser.appserviceUserId, roomId); + } + } + + if (!currentConfig || !currentConfig.feeds) currentConfig = {feeds: {}}; + + const allUrls = feedUrls.concat(Object.keys(currentConfig.feeds)); + for (const feedUrl of allUrls) { + let feed = currentConfig.feeds[feedUrl]; + if (!feed) feed = {poll_interval_mins: 60, rooms: []}; + + const hasRoom = feed.rooms.indexOf(roomId) !== -1; + const isEnabled = feedUrls.indexOf(feedUrl) !== -1; + + if (hasRoom && !isEnabled) { + feed.rooms.splice(feed.rooms.indexOf(roomId), 1); + } else if (!hasRoom && isEnabled) { + feed.rooms.push(roomId); + } + + if (feed.rooms.length > 0) { + newConfig.feeds[feedUrl] = { + poll_interval_mins: feed.poll_interval_mins, + rooms: feed.rooms, + }; + } + } + + if (this.neb.upstreamId) { + await this.doUpstreamRequest("/integrations/rssbot/configureService", { + room_id: roomId, + feeds: newConfig.feeds, + }); + } else { + const client = new NebClient(this.neb); + const notifUser = await NebStore.getOrCreateNotificationUser(this.neb.id, "rss", this.requestingUserId); + await client.setServiceConfig(notifUser.serviceId, notifUser.appserviceUserId, "rssbot", newConfig); + } + } public async removeBotFromRoom(integration: NebIntegration, roomId: string): Promise { if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy"); diff --git a/web/app/configs/complex-bot/complex-bot.component.ts b/web/app/configs/complex-bot/complex-bot.component.ts index 7f3122e..c463b98 100644 --- a/web/app/configs/complex-bot/complex-bot.component.ts +++ b/web/app/configs/complex-bot/complex-bot.component.ts @@ -5,6 +5,7 @@ import { Subscription } from "rxjs/Subscription"; import { IntegrationsApiService } from "../../shared/services/integrations/integrations-api.service"; import { ToasterService } from "angular2-toaster"; import { ServiceLocator } from "../../shared/registry/locator.service"; +import { ScalarClientApiService } from "../../shared/services/scalar/scalar-client-api.service"; export class ComplexBotComponent implements OnInit, OnDestroy { @@ -12,14 +13,14 @@ export class ComplexBotComponent implements OnInit, OnDestroy { public isUpdating = false; public bot: FE_ComplexBot; public newConfig: T; - - private roomId: string; + public roomId: string; private routeQuerySubscription: Subscription; protected toaster = ServiceLocator.injector.get(ToasterService); protected integrationsApi = ServiceLocator.injector.get(IntegrationsApiService); protected route = ServiceLocator.injector.get(ActivatedRoute); + protected scalarClientApi = ServiceLocator.injector.get(ScalarClientApiService); constructor(private integrationType: string) { this.isLoading = true; @@ -45,10 +46,25 @@ export class ComplexBotComponent implements OnInit, OnDestroy { this.integrationsApi.getIntegrationInRoom("complex-bot", this.integrationType, this.roomId).then(i => { this.bot = >i; + this.newConfig = JSON.parse(JSON.stringify(this.bot.config)); this.isLoading = false; }).catch(err => { console.error(err); this.toaster.pop("error", "Failed to load configuration"); }); } + + public save(): void { + this.isUpdating = true; + this.integrationsApi.setIntegrationConfiguration("complex-bot", this.integrationType, this.roomId, this.newConfig).then(() => { + this.toaster.pop("success", "Configuration updated"); + this.bot.config = this.newConfig; + this.newConfig = JSON.parse(JSON.stringify(this.bot.config)); + this.isUpdating = false; + }).catch(err => { + console.error(err); + this.toaster.pop("error", "Error updating configuration"); + this.isUpdating = false; + }); + } } \ No newline at end of file diff --git a/web/app/configs/complex-bot/config-screen/config-screen.complex-bot.component.html b/web/app/configs/complex-bot/config-screen/config-screen.complex-bot.component.html index 34caaba..0ebd50e 100644 --- a/web/app/configs/complex-bot/config-screen/config-screen.complex-bot.component.html +++ b/web/app/configs/complex-bot/config-screen/config-screen.complex-bot.component.html @@ -2,20 +2,5 @@
- -
- {{ botComponent.bot.displayName }} configuration -
-
-
- - -
- -
-
-
-
+
\ No newline at end of file diff --git a/web/app/configs/complex-bot/rss/rss.complex-bot.component.html b/web/app/configs/complex-bot/rss/rss.complex-bot.component.html index 5607fb2..e588d65 100644 --- a/web/app/configs/complex-bot/rss/rss.complex-bot.component.html +++ b/web/app/configs/complex-bot/rss/rss.complex-bot.component.html @@ -1,5 +1,58 @@ -

{{ bot | json }}

+ +
+ Feeds +
+
+
+ + + + + + + + + + + + + + + + + + +
URLAdded byActions
{{ feed.url }}{{ feed.addedByUserId }} + +
+
+ + + + +
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/web/app/configs/complex-bot/rss/rss.complex-bot.component.scss b/web/app/configs/complex-bot/rss/rss.complex-bot.component.scss index e69de29..7c9eeab 100644 --- a/web/app/configs/complex-bot/rss/rss.complex-bot.component.scss +++ b/web/app/configs/complex-bot/rss/rss.complex-bot.component.scss @@ -0,0 +1,4 @@ +.actions-col { + width: 120px; + text-align: center; +} \ No newline at end of file diff --git a/web/app/configs/complex-bot/rss/rss.complex-bot.component.ts b/web/app/configs/complex-bot/rss/rss.complex-bot.component.ts index 04587f1..0dfe578 100644 --- a/web/app/configs/complex-bot/rss/rss.complex-bot.component.ts +++ b/web/app/configs/complex-bot/rss/rss.complex-bot.component.ts @@ -1,18 +1,66 @@ import { ComplexBotComponent } from "../complex-bot.component"; import { Component } from "@angular/core"; +import { SessionStorage } from "../../../shared/SessionStorage"; interface RssConfig { feeds: { - [feedUrl: string]: {}; // No options currently + [feedUrl: string]: { + addedByUserId: string; + }; }; } +interface LocalFeed { + url: string; + addedByUserId: string; + isSelf: boolean; +} + @Component({ templateUrl: "rss.complex-bot.component.html", styleUrls: ["rss.complex-bot.component.scss"], }) export class RssComplexBotConfigComponent extends ComplexBotComponent { + + public newFeedUrl = ""; + constructor() { super("rss"); } + + public addFeed(): void { + if (!this.newFeedUrl.trim()) { + this.toaster.pop('warning', 'Please enter a feed URL'); + return; + } + + this.newConfig.feeds[this.newFeedUrl] = {addedByUserId: SessionStorage.userId}; + this.newFeedUrl = ""; + } + + public getFeeds(): LocalFeed[] { + if (!this.newConfig.feeds) this.newConfig.feeds = {}; + return Object.keys(this.newConfig.feeds).map(url => { + return { + url: url, + addedByUserId: this.newConfig.feeds[url].addedByUserId, + isSelf: SessionStorage.userId === this.newConfig.feeds[url].addedByUserId, + }; + }); + } + + public removeFeed(feed: LocalFeed): void { + delete this.newConfig.feeds[feed.url]; + } + + public async interceptSave(): Promise { + const memberEvent = await this.scalarClientApi.getMembershipState(this.roomId, this.bot.notificationUserId); + const isJoined = memberEvent && memberEvent.response && ["join", "invite"].indexOf(memberEvent.response.membership) !== -1; + + if (!isJoined) { + await this.scalarClientApi.inviteUser(this.roomId, this.bot.notificationUserId); + } + + super.save(); + } } \ No newline at end of file diff --git a/web/app/shared/services/integrations/integrations-api.service.ts b/web/app/shared/services/integrations/integrations-api.service.ts index 21bd353..ecde3f4 100644 --- a/web/app/shared/services/integrations/integrations-api.service.ts +++ b/web/app/shared/services/integrations/integrations-api.service.ts @@ -22,6 +22,10 @@ export class IntegrationsApiService extends AuthedApi { return this.authedGet("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type).map(r => r.json()).toPromise(); } + public setIntegrationConfiguration(category: string, type: string, roomId: string, newConfig: any): Promise { + return this.authedPost("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type + "/config", newConfig).map(r => r.json()).toPromise(); + } + public getWidget(type: string): Promise { return this.getIntegration("widget", type).then(i => i); }