Support vector's RSS bot. Adds #13

This has a side effect of adding #23 as well. A more performant caching method is probably needed (as this doesn't cache at all).
This commit is contained in:
turt2live 2017-05-28 22:51:04 -06:00
parent 3aa60b66a6
commit 58feb07119
23 changed files with 412 additions and 31 deletions

View File

@ -6,4 +6,3 @@ about: "Tracks any Atom/RSS feed and sends new items into this room"
avatar: "/img/avatars/rssbot.png"
upstream:
type: "vector"
id: "rssbot"

View File

@ -44,6 +44,7 @@
"@angularclass/hmr-loader": "^3.0.2",
"@ng-bootstrap/ng-bootstrap": "^1.0.0-alpha.22",
"@types/node": "^7.0.18",
"angular2-modal": "^2.0.3",
"angular2-template-loader": "^0.6.2",
"angular2-toaster": "^4.0.0",
"angular2-ui-switch": "^1.2.0",

View File

@ -24,6 +24,7 @@ class DimensionApi {
app.get("/api/v1/dimension/integrations/:roomId", this._getIntegrations.bind(this));
app.delete("/api/v1/dimension/integrations/:roomId/:type/:integrationType", this._removeIntegration.bind(this));
app.put("/api/v1/dimension/integrations/:roomId/:type/:integrationType/state", this._updateIntegrationState.bind(this));
}
_getIntegration(integrationConfig, roomId, scalarToken) {
@ -103,6 +104,38 @@ class DimensionApi {
res.status(500).send({error: err.message});
});
}
_updateIntegrationState(req, res) {
var roomId = req.params.roomId;
var scalarToken = req.body.scalar_token;
var type = req.params.type;
var integrationType = req.params.integrationType;
if (!roomId || !scalarToken || !type || !integrationType) {
res.status(400).send({error: "Missing room, integration type, type, or token"});
return;
}
var integrationConfig = Integrations.byType[type][integrationType];
if (!integrationConfig) {
res.status(400).send({error: "Unknown integration"});
return;
}
log.info("DimensionApi", "Update state requested for " + type + " (" + integrationType + ") in room " + roomId);
this._db.checkToken(scalarToken).then(() => {
return this._getIntegration(integrationConfig, roomId, scalarToken);
}).then(integration => {
return integration.updateState(req.body.state);
}).then(newState => {
res.status(200).send(newState);
}).catch(err => {
log.error("DimensionApi", err);
console.error(err);
res.status(500).send({error: err.message});
});
}
}
module.exports = new DimensionApi();

View File

@ -30,6 +30,15 @@ class IntegrationStub {
removeFromRoom(roomId) {
throw new Error("Not implemented");
}
/**
* Updates the state information for this integration. The data passed is an implementation detail.
* @param {*} newState the new state
* @returns {Promise<*>} resolves when completed, with the new state of the integration
*/
updateState(newState) {
return Promise.resolve({});
}
}
module.exports = IntegrationStub;

View File

@ -22,8 +22,16 @@ class RSSBot extends ComplexBot {
/*override*/
getState() {
var response = {
feeds: [],
immutableFeeds: []
};
return this._backbone.getFeeds().then(feeds => {
return {feeds: feeds};
response.feeds = feeds;
return this._backbone.getImmutableFeeds();
}).then(feeds => {
response.immutableFeeds = feeds;
return response;
});
}
@ -31,6 +39,11 @@ class RSSBot extends ComplexBot {
removeFromRoom(roomId) {
return this._backbone.removeFromRoom(roomId);
}
/*override*/
updateState(newState) {
return this._backbone.setFeeds(newState.feeds).then(() => this.getState());
}
}
module.exports = RSSBot;

View File

@ -25,6 +25,23 @@ class StubbedRssBackbone {
throw new Error("Not implemented");
}
/**
* Sets the new feeds for this backbone
* @param {string[]} newFeeds the new feed URLs
* @returns {Promise<>} resolves when complete
*/
setFeeds(newFeeds) {
throw new Error("Not implemented");
}
/**
* Gets the immutable feeds for this backbone
* @returns {Promise<{url:string,ownerId:string}>} resolves to the collection of immutable feeds
*/
getImmutableFeeds() {
throw new Error("Not implemented");
}
/**
* Removes the bot from the given room
* @param {string} roomId the room ID to remove the bot from

View File

@ -18,6 +18,7 @@ class VectorRssBackbone extends StubbedRssBackbone {
this._roomId = roomId;
this._scalarToken = upstreamScalarToken;
this._info = null;
this._otherFeeds = [];
}
/*override*/
@ -35,15 +36,46 @@ class VectorRssBackbone extends StubbedRssBackbone {
});
}
/*override*/
setFeeds(newFeeds) {
var feedConfig = {};
for (var feed of newFeeds) feedConfig[feed] = {};
return VectorScalarClient.configureIntegration("rssbot", this._scalarToken, {
feeds: feedConfig,
room_id: this._roomId
});
}
/*override*/
getImmutableFeeds() {
return (this._info ? Promise.resolve() : this._getInfo()).then(() => {
return this._otherFeeds;
});
}
_getInfo() {
return VectorScalarClient.getIntegration("rssbot", this._roomId, this._scalarToken).then(info => {
return VectorScalarClient.getIntegrationsForRoom(this._roomId, this._scalarToken).then(integrations => {
this._otherFeeds = [];
for (var integration of integrations) {
if (integration.self) continue; // skip - we're not looking for ones we know about
if (integration.type == "rssbot") {
var urls = _.keys(integration.config.feeds);
for (var url of urls) {
this._otherFeeds.push({url: url, ownerId: integration.user_id});
}
}
}
return VectorScalarClient.getIntegration("rssbot", this._roomId, this._scalarToken);
}).then(info => {
this._info = info;
});
}
/*override*/
removeFromRoom(roomId) {
return VectorScalarClient.removeIntegration(this._config.upstream.id, roomId, this._upstreamToken);
return VectorScalarClient.removeIntegration("rssbot", roomId, this._scalarToken);
}
}

View File

@ -6,7 +6,7 @@ module.exports = (db, integrationConfig, roomId, scalarToken) => {
if (integrationConfig.upstream) {
if (integrationConfig.upstream.type !== "vector") throw new Error("Unsupported upstream");
return db.getUpstreamToken(scalarToken).then(upstreamToken => {
var backbone = new VectorSimpleBackbone(roomId, upstreamToken);
var backbone = new VectorSimpleBackbone(integrationConfig, upstreamToken);
return new SimpleBot(integrationConfig, backbone);
});
} else if (integrationConfig.hosted) {

View File

@ -54,7 +54,7 @@ class VectorScalarClient {
* @return {Promise<>} resolves when completed
*/
configureIntegration(type, scalarToken, config) {
return this._do("POST", "/integrations/"+type+"/configureService", {scalar_token:scalarToken}, config).then((response, body) => {
return this._do("POST", "/integrations/" + type + "/configureService", {scalar_token: scalarToken}, config).then((response, body) => {
if (response.statusCode !== 200) {
log.error("VectorScalarClient", response.body);
return Promise.reject(response.body);
@ -64,6 +64,23 @@ class VectorScalarClient {
});
}
/**
* Gets all of the integrations currently in a room
* @param {string} roomId the room ID
* @param {string} scalarToken the scalar token to use
* @returns {Promise<*[]>} resolves a collection of integrations
*/
getIntegrationsForRoom(roomId, scalarToken) {
return this._do("POST", "/integrations", {scalar_token: scalarToken}, {RoomId: roomId}).then((response, body) => {
if (response.statusCode !== 200) {
log.error("VectorScalarClient", response.body);
return Promise.reject(response.body);
}
return response.body.integrations;
});
}
/**
* Gets information on
* @param {string} type the type to lookup
@ -72,7 +89,7 @@ class VectorScalarClient {
* @return {Promise<{bot_user_id:string,integrations:[]}>} resolves to the integration information
*/
getIntegration(type, roomId, scalarToken) {
return this._do("POST", "/integrations/"+type,{scalar_token:scalarToken}, {room_id:roomId}).then((response, body) => {
return this._do("POST", "/integrations/" + type, {scalar_token: scalarToken}, {room_id: roomId}).then((response, body) => {
if (response.statusCode !== 200) {
log.error("VectorScalarClient", response.body);
return Promise.reject(response.body);

View File

@ -15,6 +15,10 @@ import { ToasterModule } from "angular2-toaster";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { IntegrationComponent } from "./integration/integration.component";
import { ScalarCloseComponent } from "./riot/scalar-close/scalar-close.component";
import { IntegrationService } from "./shared/integration.service";
import { BootstrapModalModule } from "angular2-modal/plugins/bootstrap";
import { ModalModule } from "angular2-modal";
import { RssConfigComponent } from "./configs/rss/rss-config.component";
@NgModule({
imports: [
@ -26,6 +30,8 @@ import { ScalarCloseComponent } from "./riot/scalar-close/scalar-close.component
UiSwitchModule,
ToasterModule,
BrowserAnimationsModule,
ModalModule.forRoot(),
BootstrapModalModule,
],
declarations: [
AppComponent,
@ -33,17 +39,21 @@ import { ScalarCloseComponent } from "./riot/scalar-close/scalar-close.component
RiotComponent,
IntegrationComponent,
ScalarCloseComponent,
RssConfigComponent,
// Vendor
],
providers: [
ApiService,
ScalarService,
IntegrationService,
// Vendor
],
bootstrap: [AppComponent],
entryComponents: []
entryComponents: [
RssConfigComponent,
]
})
export class AppModule {
constructor(public appRef: ApplicationRef) {

View File

@ -0,0 +1,32 @@
// shared styling for all config screens
.config-wrapper {
padding: 25px;
}
.config-header {
padding-bottom: 8px;
margin-bottom: 14px;
border-bottom: 1px solid #dadada;
}
.config-header h4 {
display: inline-block;
vertical-align: middle;
}
.config-header img {
margin-right: 7px;
width: 35px;
height: 35px;
border-radius: 35px;
}
.close-icon {
float: right;
margin: -17px;
cursor: pointer;
}
.config-content {
display: block;
}

View File

@ -0,0 +1,38 @@
<div class="config-wrapper">
<img src="/img/close.svg" (click)="dialog.close()" class="close-icon">
<div class="config-header">
<img src="/img/avatars/rssbot.png">
<h4>Configure RSS/Atom Feeds</h4>
</div>
<div class="config-content">
<form (submit)="addFeed()" novalidate name="addFeedForm">
<div class="row">
<div class="col-md-8" style="margin-bottom: 12px;">
<div class="input-group input-group-sm">
<input type="text" class="form-control"
[(ngModel)]="feedUrl" name="feedUrl"
[disabled]="isUpdating">
<span class="input-group-btn">
<button type="submit" class="btn btn-success" [disabled]="isUpdating">
<i class="fa fa-plus-circle"></i> Add Feed
</button>
</span>
</div>
</div>
<div class="col-md-12" *ngFor="let feed of integration.feeds trackById">
<a [href]="feed" rel="noopener" target="_blank">{{ feed }}</a>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeFeed(feed)"
style="margin-top: -5px;" [disabled]="isUpdating">
<i class="fa fa-times"></i> Remove Feed
</button>
</div>
<div class="col-md-12" *ngIf="integration.immutableFeeds.length > 0">
<h6 class="other-feeds-title">Feeds from other users in the room</h6>
</div>
<div class="col-md-12 feed-list" *ngFor="let feed of integration.immutableFeeds trackById">
<a [href]="feed.url" rel="noopener" target="_blank">{{ feed.url }}</a> <span class="text-muted">(added by {{ feed.ownerId }})</span>
</div>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,9 @@
// component styles are encapsulated and only applied to their components
.feed-list {
margin-top: 5px;
}
.other-feeds-title {
margin-top: 25px;
margin-bottom: 0;
}

View File

@ -0,0 +1,67 @@
import { Component } from "@angular/core";
import { RSSIntegration } from "../../shared/models/integration";
import { ModalComponent, DialogRef } from "angular2-modal";
import { ConfigModalContext } from "../../integration/integration.component";
import { ToasterService } from "angular2-toaster";
import { ApiService } from "../../shared/api.service";
@Component({
selector: 'my-rss-config',
templateUrl: './rss-config.component.html',
styleUrls: ['./rss-config.component.scss', './../config.component.scss'],
})
export class RssConfigComponent implements ModalComponent<ConfigModalContext> {
public integration: RSSIntegration;
public isUpdating = false;
public feedUrl = "";
private roomId: string;
private scalarToken: string;
constructor(public dialog: DialogRef<ConfigModalContext>,
private toaster: ToasterService,
private api: ApiService) {
this.integration = <RSSIntegration>dialog.context.integration;
this.roomId = dialog.context.roomId;
this.scalarToken = dialog.context.scalarToken;
}
public addFeed() {
if (!this.feedUrl || this.feedUrl.trim().length === 0) {
this.toaster.pop("warning", "Please enter a feed URL");
return;
}
if (this.integration.feeds.indexOf(this.feedUrl) !== -1) {
this.toaster.pop("error", "This feed has already been added");
}
let feedCopy = JSON.parse(JSON.stringify(this.integration.feeds));
feedCopy.push(this.feedUrl);
this.updateFeeds(feedCopy);
}
public removeFeed(feedUrl) {
let feedCopy = JSON.parse(JSON.stringify(this.integration.feeds));
const idx = feedCopy.indexOf(feedUrl);
feedCopy.splice(idx, 1);
this.updateFeeds(feedCopy);
}
private updateFeeds(newFeeds) {
this.isUpdating = true;
this.api.updateIntegrationState(this.roomId, this.integration.type, this.integration.integrationType, this.scalarToken, {
feeds: newFeeds
}).then(response => {
this.integration.feeds = response.feeds;
this.integration.immutableFeeds = response.immutableFeeds;
this.isUpdating = false;
this.toaster.pop("success", "Feeds updated");
}).catch(err => {
this.toaster.pop("error", err.json().error);
console.error(err);
this.isUpdating = false;
});
}
}

View File

@ -8,6 +8,7 @@
</div>
<div class="toolbar">
<i class="fa fa-question-circle text-info" ngbTooltip="{{integration.about}}" *ngIf="integration.about"></i>
<i class="fa fa-cog text-info config-icon" (click)="configureIntegration()" *ngIf="integration.isEnabled && integration.hasConfig"></i>
</div>
</div>
</div>

View File

@ -32,3 +32,7 @@
vertical-align: top;
margin-left: 5px;
}
.config-icon {
cursor: pointer;
}

View File

@ -1,5 +1,14 @@
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Component, Input, Output, EventEmitter, ViewContainerRef } from "@angular/core";
import { Integration } from "../shared/models/integration";
import { Overlay, overlayConfigFactory } from "angular2-modal";
import { Modal, BSModalContext } from "angular2-modal/plugins/bootstrap";
import { IntegrationService } from "../shared/integration.service";
export class ConfigModalContext extends BSModalContext {
public integration: Integration;
public roomId: string;
public scalarToken: string;
}
@Component({
selector: 'my-integration',
@ -9,13 +18,26 @@ import { Integration } from "../shared/models/integration";
export class IntegrationComponent {
@Input() integration: Integration;
@Input() roomId: string;
@Input() scalarToken: string;
@Output() updated: EventEmitter<any> = new EventEmitter();
constructor() {
constructor(overlay: Overlay, vcRef: ViewContainerRef, public modal: Modal) {
overlay.defaultViewContainer = vcRef;
}
public update(): void {
this.integration.isEnabled = !this.integration.isEnabled;
this.updated.emit();
}
public configureIntegration(): void {
this.modal.open(IntegrationService.getConfigComponent(this.integration), overlayConfigFactory({
integration: this.integration,
roomId: this.roomId,
scalarToken: this.scalarToken,
isBlocking: false,
size: 'lg'
}, BSModalContext));
}
}

View File

@ -8,9 +8,14 @@
</div>
<div *ngIf="!error && !loading">
<h3>Manage Integrations</h3>
<p>Turn on anything you like. If an integration has some special configuration, it will have a configuration icon next to it.</p>
<p>Turn on anything you like. If an integration has some special configuration, it will have a configuration
icon next to it.</p>
<div class="integration-container">
<my-integration *ngFor="let integration of integrations" [integration]="integration" (updated)="updateIntegration(integration)"></my-integration>
<my-integration *ngFor="let integration of integrations"
[integration]="integration"
[roomId]="roomId"
[scalarToken]="scalarToken"
(updated)="updateIntegration(integration)"></my-integration>
</div>
</div>
</div>

View File

@ -4,6 +4,8 @@ import { ApiService } from "../shared/api.service";
import { ScalarService } from "../shared/scalar.service";
import { ToasterService } from "angular2-toaster";
import { Integration } from "../shared/models/integration";
import { IntegrationService } from "../shared/integration.service";
import * as _ from "lodash";
@Component({
selector: 'my-riot',
@ -16,8 +18,7 @@ export class RiotComponent {
public integrations: Integration[] = [];
public loading = true;
public roomId: string;
private scalarToken: string;
public scalarToken: string;
constructor(private activatedRoute: ActivatedRoute,
private api: ApiService,
@ -41,7 +42,7 @@ export class RiotComponent {
private init() {
this.api.getIntegrations(this.roomId, this.scalarToken).then(integrations => {
this.integrations = integrations;
this.integrations = _.filter(integrations, i => IntegrationService.isSupported(i));
let promises = integrations.map(b => this.updateIntegrationState(b));
return Promise.all(promises);
}).then(() => this.loading = false).catch(err => {
@ -51,6 +52,8 @@ export class RiotComponent {
}
private updateIntegrationState(integration: Integration) {
integration.hasConfig = IntegrationService.hasConfig(integration);
return this.scalar.getMembershipState(this.roomId, integration.userId).then(payload => {
integration.isBroken = false;
@ -74,21 +77,19 @@ export class RiotComponent {
promise = this.api.removeIntegration(this.roomId, integration.type, integration.integrationType, this.scalarToken);
} else promise = this.scalar.inviteUser(this.roomId, integration.userId);
promise
.then(() => {
if (integration.isEnabled)
this.toaster.pop('success', integration.name + " was invited to the room");
else this.toaster.pop('success', integration.name + " was removed from the room");
})
.catch(err => {
let errorMessage = "Could not update integration status";
promise.then(() => {
if (integration.isEnabled)
this.toaster.pop('success', integration.name + " was invited to the room");
else this.toaster.pop('success', integration.name + " was removed from the room");
}).catch(err => {
let errorMessage = "Could not update integration status";
if (err.json) {
errorMessage = err.json().error;
} else errorMessage = err.response.error.message;
if (err.json) {
errorMessage = err.json().error;
} else errorMessage = err.response.error.message;
integration.isEnabled = !integration.isEnabled;
this.toaster.pop("error", errorMessage);
});
integration.isEnabled = !integration.isEnabled;
this.toaster.pop("error", errorMessage);
});
}
}

View File

@ -18,7 +18,14 @@ export class ApiService {
}
removeIntegration(roomId: string, type: string, integrationType: string, scalarToken: string): Promise<any> {
return this.http.delete("/api/v1/dimension/integrations/" + roomId + "/" + type + "/" + integrationType, {params: {scalar_token: scalarToken}})
const url = "/api/v1/dimension/integrations/" + roomId + "/" + type + "/" + integrationType;
return this.http.delete(url, {params: {scalar_token: scalarToken}})
.map(res => res.json()).toPromise();
}
updateIntegrationState(roomId: string, type: string, integrationType: string, scalarToken: string, newState: any): Promise<any> {
const url = "/api/v1/dimension/integrations/" + roomId + "/" + type + "/" + integrationType + "/state";
return this.http.put(url, {scalar_token: scalarToken, state: newState})
.map(res => res.json()).toPromise();
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from "@angular/core";
import { Integration } from "./models/integration";
import { RssConfigComponent } from "../configs/rss/rss-config.component";
import { ContainerContent } from "angular2-modal";
@Injectable()
export class IntegrationService {
private static supportedTypeMap = {
"bot": true,
"complex-bot": {
"rss": true
}
};
private static components = {
"complex-bot": {
"rss": RssConfigComponent
}
};
static isSupported(integration: Integration): boolean {
if (IntegrationService.supportedTypeMap[integration.type] === true) return true;
if (!IntegrationService.supportedTypeMap[integration.type]) return false;
return IntegrationService.supportedTypeMap[integration.type][integration.integrationType] === true;
}
static hasConfig(integration: Integration): boolean {
return integration.type !== "bot";
}
static getConfigComponent(integration: Integration): ContainerContent {
return IntegrationService.components[integration.type][integration.integrationType];
}
constructor() {
}
}

View File

@ -7,4 +7,10 @@ export interface Integration {
about: string; // nullable
isEnabled: boolean;
isBroken: boolean;
hasConfig: boolean;
}
export interface RSSIntegration extends Integration {
feeds: string[];
immutableFeeds: {url: string, ownerId: string}[];
}

View File

@ -7,3 +7,23 @@ body {
padding: 0;
color: #222;
}
// HACK: Work around dialog not showing up
// ref: https://github.com/shlomiassaf/angular2-modal/issues/280
.fade.in {
opacity: 1;
}
.modal.in .modal-dialog {
-webkit-transform: translate(0, 0);
-o-transform: translate(0, 0);
transform: translate(0, 0);
}
.modal-backdrop.in {
opacity: 0.5;
}
button {
cursor: pointer;
}