Add self-service configuration for the RSS bot

Fixes #14
This commit is contained in:
Travis Ralston 2018-03-26 21:48:44 -06:00
parent 1233be85e9
commit 18597db540
15 changed files with 343 additions and 30 deletions

View file

@ -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<any> {
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<any> {
@ -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
}

View file

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

View file

@ -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<any> {
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<any> {
const rawIntegrations = await NebStore.listEnabledNebSimpleBots();
const integration = rawIntegrations.find(i => i.integration.type === type);

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

View file

@ -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<NebIntegrationConfig> {
@PrimaryKey
@AutoIncrement
@Column
id: number;
@Column
@ForeignKey(() => NebIntegration)
integrationId: string;
@Column
roomId: string;
@Column
jsonContent: string;
}

View file

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

View file

@ -1,3 +1,4 @@
export interface ModularIntegrationInfoResponse {
bot_user_id: string;
integrations?: any[];
}

View file

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

View file

@ -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<string> {
public async getNotificationUserId(integration: NebIntegration, inRoomId: string): Promise<string> {
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<any> {
//
// }
public async getServiceConfiguration(integration: NebIntegration, inRoomId: string): Promise<any> {
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<ModularIntegrationInfoResponse>("/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<any> {
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<any> {
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<ModularIntegrationInfoResponse>("/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<ModularIntegrationInfoResponse>("/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<any> {
if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy");

View file

@ -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<T> implements OnInit, OnDestroy {
@ -12,14 +13,14 @@ export class ComplexBotComponent<T> implements OnInit, OnDestroy {
public isUpdating = false;
public bot: FE_ComplexBot<T>;
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<T> implements OnInit, OnDestroy {
this.integrationsApi.getIntegrationInRoom("complex-bot", this.integrationType, this.roomId).then(i => {
this.bot = <FE_ComplexBot<T>>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;
});
}
}

View file

@ -2,20 +2,5 @@
<my-spinner></my-spinner>
</div>
<div *ngIf="!botComponent.isLoading">
<my-ibox>
<h5 class="my-ibox-title">
{{ botComponent.bot.displayName }} configuration
</h5>
<div class="my-ibox-content">
<form (submit)="botComponent.save()" novalidate name="saveForm">
<ng-container *ngTemplateOutlet="botParamsTemplate"></ng-container>
<div style="margin-top: 25px">
<button type="submit" class="btn btn-sm btn-success" [disabled]="botComponent.isUpdating">
<i class="far fa-save"></i> Save
</button>
</div>
</form>
</div>
</my-ibox>
<ng-container *ngTemplateOutlet="botParamsTemplate"></ng-container>
</div>

View file

@ -1,5 +1,58 @@
<my-complex-bot-config [botComponent]="this">
<ng-template #botParamsTemplate>
<p>{{ bot | json }}</p>
<my-ibox>
<h5 class="my-ibox-title">
Feeds
</h5>
<div class="my-ibox-content">
<form (submit)="interceptSave()" novalidate name="saveForm">
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>URL</th>
<th>Added by</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let feed of getFeeds()">
<td>{{ feed.url }}</td>
<td>{{ feed.addedByUserId }}</td>
<td class="actions-col">
<button type="button" class="btn btn-sm btn-outline-danger"
[disabled]="isUpdating || !feed.isSelf"
(click)="removeFeed(feed)">
<i class="far fa-trash-alt"></i> Remove
</button>
</td>
</tr>
<tr>
<td colspan="3">
<div class="input-group input-group-sm">
<input type="text" class="form-control"
[(ngModel)]="newFeedUrl"
placeholder="https://example.org/feed.atom"
name="newFeedUrl"
title="New feed URL" />
<span class="input-group-btn">
<button type="button" class="btn btn-outline-success"
[disabled]="isUpdating"
(click)="addFeed()">
<i class="fa fa-plus"></i> Add
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 25px">
<button type="submit" class="btn btn-sm btn-primary" [disabled]="isUpdating">
<i class="far fa-save"></i> Save
</button>
</div>
</form>
</div>
</my-ibox>
</ng-template>
</my-complex-bot-config>

View file

@ -0,0 +1,4 @@
.actions-col {
width: 120px;
text-align: center;
}

View file

@ -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<RssConfig> {
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<any> {
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();
}
}

View file

@ -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<any> {
return this.authedPost("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type + "/config", newConfig).map(r => r.json()).toPromise();
}
public getWidget(type: string): Promise<FE_Widget> {
return this.getIntegration("widget", type).then(i => <FE_Widget>i);
}