Very early support for configuring complex bots

Using the RSS Bot as an example. Notably missing features:
* Configuration (feeds) not retrieved
* No actual configuration page
This commit is contained in:
Travis Ralston 2018-03-25 21:01:05 -06:00
parent b5aec06c04
commit 2c1366d9d7
19 changed files with 276 additions and 23 deletions

View File

@ -7,10 +7,12 @@ import { ApiError } from "../ApiError";
import { WidgetStore } from "../../db/WidgetStore";
import { SimpleBot } from "../../integrations/SimpleBot";
import { NebStore } from "../../db/NebStore";
import { ComplexBot } from "../../integrations/ComplexBot";
export interface IntegrationsResponse {
widgets: Widget[],
bots: SimpleBot[],
complexBots: ComplexBot[],
}
@Path("/api/v1/dimension/integrations")
@ -34,22 +36,35 @@ export class DimensionIntegrationsService {
return bots;
}
@GET
@Path("enabled")
public async getEnabledIntegrations(@QueryParam("scalar_token") scalarToken: string): Promise<IntegrationsResponse> {
const userId = await ScalarService.getTokenOwner(scalarToken);
return {
widgets: await DimensionIntegrationsService.getWidgets(true),
bots: await DimensionIntegrationsService.getSimpleBots(userId),
};
public static async getComplexBots(userId: string, roomId: string): Promise<ComplexBot[]> {
const cached = Cache.for(CACHE_INTEGRATIONS).get("complex_bots_" + roomId);
if (cached) return cached;
const bots = await NebStore.listComplexBots(userId, roomId);
Cache.for(CACHE_INTEGRATIONS).put("complex_bots_" + roomId, bots);
return bots;
}
@GET
@Path("room/:roomId")
public async getIntegrationsInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise<IntegrationsResponse> {
console.log(roomId);
// TODO: Other integrations
return this.getEnabledIntegrations(scalarToken);
const userId = await ScalarService.getTokenOwner(scalarToken);
return {
widgets: await DimensionIntegrationsService.getWidgets(true),
bots: await DimensionIntegrationsService.getSimpleBots(userId),
complexBots: await DimensionIntegrationsService.getComplexBots(userId, roomId),
};
}
@GET
@Path("room/:roomId/integrations/:category/:type")
public async getIntegrationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string): Promise<any> {
const roomConfig = await this.getIntegrationsInRoom(scalarToken, roomId); // does auth for us
if (category === "widget") return roomConfig.widgets.find(i => i.type === integrationType);
else if (category === "bot") return roomConfig.bots.find(i => i.type === integrationType);
else if (category === "complex-bot") return roomConfig.complexBots.find(i => i.type === integrationType);
else throw new ApiError(400, "Unrecognized category");
}
@DELETE

View File

@ -11,6 +11,7 @@ import { AppserviceStore } from "./AppserviceStore";
import config from "../config";
import { SimpleBot } from "../integrations/SimpleBot";
import { NebProxy } from "../neb/NebProxy";
import { ComplexBot } from "../integrations/ComplexBot";
export interface SupportedIntegration {
type: string;
@ -29,6 +30,7 @@ export class NebStore {
// name: "Circle CI",
// avatarUrl: "/img/avatars/circleci.png",
// description: "Announces build results from Circle CI to the room.",
// simple: false,
// },
"echo": {
name: "Echo",
@ -53,6 +55,7 @@ export class NebStore {
// name: "Github",
// avatarUrl: "/img/avatars/github.png",
// description: "Github issue management and announcements for a repository",
// simple: false,
// },
"google": {
name: "Google",
@ -71,16 +74,19 @@ export class NebStore {
// name: "Jira",
// avatarUrl: "/img/avatars/jira.png",
// description: "Jira issue management and announcements for a project",
// simple: false,
// },
"rss": {
name: "RSS",
avatarUrl: "/img/avatars/rssbot.png",
description: "Announces changes to RSS feeds in the room",
simple: false,
},
"travisci": {
name: "Travis CI",
avatarUrl: "/img/avatars/travisci.png",
description: "Announces build results from Travis CI to the room",
simple: false,
},
"wikipedia": {
name: "Wikipedia",
@ -90,9 +96,9 @@ export class NebStore {
},
};
public static async listEnabledNebSimpleBots(): Promise<{neb: NebConfig, integration: NebIntegration}[]>{
private static async listEnabledNebBots(simple: boolean): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
const nebConfigs = await NebStore.getAllConfigs();
const integrations: {neb: NebConfig, integration: NebIntegration}[] = [];
const integrations: { neb: NebConfig, integration: NebIntegration }[] = [];
const hasTypes: string[] = [];
for (const neb of nebConfigs) {
@ -100,7 +106,7 @@ export class NebStore {
if (!integration.isEnabled) continue;
const metadata = NebStore.INTEGRATIONS[integration.type];
if (!metadata || !metadata.simple) continue;
if (!metadata || metadata.simple !== simple) continue;
if (hasTypes.indexOf(integration.type) !== -1) continue;
integrations.push({neb, integration});
@ -111,11 +117,30 @@ export class NebStore {
return integrations;
}
public static async listEnabledNebSimpleBots(): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
return NebStore.listEnabledNebBots(true);
}
public static async listEnabledNebComplexBots(): Promise<{ neb: NebConfig, integration: NebIntegration }[]> {
return NebStore.listEnabledNebBots(false);
}
public static async listSimpleBots(requestingUserId: string): Promise<SimpleBot[]> {
const rawIntegrations = await NebStore.listEnabledNebSimpleBots();
return Promise.all(rawIntegrations.map(async i => {
const proxy = new NebProxy(i.neb, requestingUserId);
return new SimpleBot(i.integration, await proxy.getBotUserId(i.integration));
const proxy = new NebProxy(i.neb, requestingUserId);
return new SimpleBot(i.integration, await proxy.getBotUserId(i.integration));
}));
}
public static async listComplexBots(requestingUserId: string, roomId: string): Promise<ComplexBot[]> {
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 botUserId = null; // TODO: For github
// TODO: Get configuration
return new ComplexBot(i.integration, notifUserId, botUserId);
}));
}
@ -271,6 +296,33 @@ export class NebStore {
return users[0];
}
public static async getOrCreateNotificationUser(configurationId: number, integrationType: string, forUserId: string): Promise<NebNotificationUser> {
const neb = await NebStore.getConfig(configurationId);
if (!neb.appserviceId) throw new Error("Instance not bound to an appservice");
const integration = await this.getOrCreateIntegration(configurationId, integrationType);
const users = await NebNotificationUser.findAll({where: {integrationId: integration.id, ownerId: forUserId}});
if (!users || users.length === 0) {
const safeUserId = AppserviceStore.getSafeUserId(forUserId);
const appservice = await AppserviceStore.getAppservice(neb.appserviceId);
const userId = "@" + appservice.userPrefix + "_" + integrationType + "_notifications_" + safeUserId + ":" + config.homeserver.name;
const appserviceUser = await AppserviceStore.getOrCreateUser(neb.appserviceId, userId);
const client = new NebClient(neb);
await client.updateUser(userId, integration.isEnabled, false); // creates the user in go-neb
const serviceId = appservice.id + "_integration_" + integrationType + "_notifications_" + safeUserId;
return NebNotificationUser.create({
serviceId: serviceId,
appserviceUserId: appserviceUser.id,
integrationId: integration.id,
ownerId: forUserId,
});
}
return users[0];
}
public static async setIntegrationConfig(configurationId: number, integrationType: string, newConfig: any): Promise<any> {
const botUser = await NebStore.getOrCreateBotUser(configurationId, integrationType);
const neb = await NebStore.getConfig(configurationId);

View File

@ -0,0 +1,13 @@
import { Integration } from "./Integration";
import NebIntegration from "../db/models/NebIntegration";
export class ComplexBot extends Integration {
constructor(bot: NebIntegration, public notificationUserId: string, public botUserId?: string) {
super(bot);
this.category = "complex-bot";
this.requirements = [];
// Notification bots are technically supported in e2e rooms
this.isEncryptionSupported = true;
}
}

View File

@ -15,7 +15,7 @@ export class NebProxy {
}
public async getBotUserId(integration: NebIntegration) {
public async getBotUserId(integration: NebIntegration): Promise<string> {
if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy");
if (this.neb.upstreamId) {
@ -31,7 +31,29 @@ export class NebProxy {
}
}
public async removeBotFromRoom(integration: NebIntegration, roomId: string) {
public async getNotificationUserId(integration: NebIntegration, inRoomId: string, forUserId: string): Promise<string> {
if (integration.nebId !== this.neb.id) throw new Error("Integration is not for this NEB proxy");
if (this.neb.upstreamId) {
try {
const response = await this.doUpstreamRequest<ModularIntegrationInfoResponse>("/integrations/" + NebClient.getNebType(integration.type), {
room_id: inRoomId,
});
return response.bot_user_id;
} catch (err) {
LogService.error("NebProxy", err);
return null;
}
} else {
return (await NebStore.getOrCreateNotificationUser(this.neb.id, integration.type, forUserId)).appserviceUserId;
}
}
// public async getComplexBotConfiguration(integration: NebIntegration, roomId: string): Promise<any> {
//
// }
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");
if (this.neb.upstreamId) {

View File

@ -1,7 +1,14 @@
Release checklist:
* Manage custom bots (not sure if anyone actually uses this?)
* RSS bot
* Travis CI
* IRC Bridge
* Update documentation
* Configuration migration (if possible)
* Final testing (widgets, bots, etc)
* Lots of logging
* Final testing (widgets, bots, etc)
After release:
* Manage custom bots
* Github
* Circle CI
* Avatars/display names for managed go-neb bots

View File

@ -4,7 +4,7 @@ import { AdminNebApiService } from "../../../shared/services/admin/admin-neb-api
import { ActivatedRoute } from "@angular/router";
import { ToasterService } from "angular2-toaster";
import { FE_Integration } from "../../../shared/models/integration";
import { NEB_HAS_CONFIG } from "../../../shared/models/neb";
import { NEB_HAS_CONFIG, NEB_IS_COMPLEX } from "../../../shared/models/neb";
import { Modal, overlayConfigFactory } from "ngx-modialog";
import { AdminNebGiphyConfigComponent } from "../config/giphy/giphy.component";
import { NebBotConfigurationDialogContext } from "../config/config-context";
@ -72,7 +72,7 @@ export class AdminEditNebComponent implements OnInit, OnDestroy {
if (bot.isEnabled && !this.isUpstream) {
if (this.hasConfig(bot)) {
this.editBot(bot);
} else {
} else if (NEB_IS_COMPLEX.indexOf(bot.type) === -1) {
try {
await this.nebApi.setIntegrationConfiguration(this.nebConfig.id, bot.type, {});
} catch (err) {

View File

@ -59,6 +59,8 @@ import { AdminNebGuggyConfigComponent } from "./admin/neb/config/guggy/guggy.com
import { AdminNebGoogleConfigComponent } from "./admin/neb/config/google/google.component";
import { AdminNebImgurConfigComponent } from "./admin/neb/config/imgur/imgur.component";
import { ConfigSimpleBotComponent } from "./configs/simple-bot/simple-bot.component";
import { ConfigScreenComplexBotComponent } from "./configs/complex-bot/config-screen/config-screen.complex-bot.component";
import { RssComplexBotConfigComponent } from "./configs/complex-bot/rss/rss.complex-bot.component";
@NgModule({
imports: [
@ -112,6 +114,8 @@ import { ConfigSimpleBotComponent } from "./configs/simple-bot/simple-bot.compon
AdminNebGoogleConfigComponent,
AdminNebImgurConfigComponent,
ConfigSimpleBotComponent,
ConfigScreenComplexBotComponent,
RssComplexBotConfigComponent,
// Vendor
],

View File

@ -19,6 +19,7 @@ import { AdminWidgetsComponent } from "./admin/widgets/widgets.component";
import { AdminNebComponent } from "./admin/neb/neb.component";
import { AdminEditNebComponent } from "./admin/neb/edit/edit.component";
import { AdminAddSelfhostedNebComponent } from "./admin/neb/add-selfhosted/add-selfhosted.component";
import { RssComplexBotConfigComponent } from "./configs/complex-bot/rss/rss.complex-bot.component";
const routes: Routes = [
{path: "", component: HomeComponent},
@ -108,6 +109,16 @@ const routes: Routes = [
},
],
},
{
path: "complex-bot",
children: [
{
path: "rss",
component: RssComplexBotConfigComponent,
data: {breadcrumb: "RSS Bot Configuration", name: "RSS Bot Configuration"}
},
],
},
],
},
{

View File

@ -0,0 +1,54 @@
import { OnDestroy, OnInit } from "@angular/core";
import { FE_ComplexBot } from "../../shared/models/integration";
import { ActivatedRoute } from "@angular/router";
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";
export class ComplexBotComponent<T> implements OnInit, OnDestroy {
public isLoading = true;
public isUpdating = false;
public bot: FE_ComplexBot<T>;
public newConfig: T;
private roomId: string;
private routeQuerySubscription: Subscription;
protected toaster = ServiceLocator.injector.get(ToasterService);
protected integrationsApi = ServiceLocator.injector.get(IntegrationsApiService);
protected route = ServiceLocator.injector.get(ActivatedRoute);
constructor(private integrationType: string) {
this.isLoading = true;
this.isUpdating = false;
}
public ngOnInit(): void {
this.routeQuerySubscription = this.route.queryParams.subscribe(params => {
this.roomId = params['roomId'];
this.loadBot();
});
}
public ngOnDestroy(): void {
if (this.routeQuerySubscription) this.routeQuerySubscription.unsubscribe();
}
private loadBot() {
this.isLoading = true;
this.isUpdating = false;
this.newConfig = <T>{};
this.integrationsApi.getIntegrationInRoom("complex-bot", this.integrationType, this.roomId).then(i => {
this.bot = <FE_ComplexBot<T>>i;
this.isLoading = false;
}).catch(err => {
console.error(err);
this.toaster.pop("error", "Failed to load configuration");
});
}
}

View File

@ -0,0 +1,21 @@
<div *ngIf="botComponent.isLoading">
<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>
</div>

View File

@ -0,0 +1,16 @@
import { ComplexBotComponent } from "../complex-bot.component";
import { Component, ContentChild, Input, TemplateRef } from "@angular/core";
@Component({
selector: "my-complex-bot-config",
templateUrl: "config-screen.complex-bot.component.html",
styleUrls: ["config-screen.complex-bot.component.scss"],
})
export class ConfigScreenComplexBotComponent {
@Input() botComponent: ComplexBotComponent<any>;
@ContentChild(TemplateRef) botParamsTemplate: TemplateRef<any>;
constructor() {
}
}

View File

@ -0,0 +1,5 @@
<my-complex-bot-config [botComponent]="this">
<ng-template #botParamsTemplate>
<p>{{ bot | json }}</p>
</ng-template>
</my-complex-bot-config>

View File

@ -0,0 +1,18 @@
import { ComplexBotComponent } from "../complex-bot.component";
import { Component } from "@angular/core";
interface RssConfig {
feeds: {
[feedUrl: string]: {}; // No options currently
};
}
@Component({
templateUrl: "rss.complex-bot.component.html",
styleUrls: ["rss.complex-bot.component.scss"],
})
export class RssComplexBotConfigComponent extends ComplexBotComponent<RssConfig> {
constructor() {
super("rss");
}
}

View File

@ -138,7 +138,7 @@ export class RiotHomeComponent {
}, SimpleBotConfigDialogContext));
} else {
console.log("Navigating to edit screen for " + integration.category + " " + integration.type);
this.router.navigate(['riot-app', integration.category, integration.type]);
this.router.navigate(['riot-app', integration.category, integration.type], {queryParams: {roomId: this.roomId}});
}
}

View File

@ -20,6 +20,12 @@ export interface FE_SimpleBot extends FE_Integration {
userId: string;
}
export interface FE_ComplexBot<T> extends FE_Integration {
notificationUserId: string;
botUserId?: string;
config: T;
}
export interface FE_Widget extends FE_Integration {
options: any;
}

View File

@ -4,4 +4,9 @@ export const NEB_HAS_CONFIG = [
"github",
"google",
"imgur",
];
export const NEB_IS_COMPLEX = [
"rss",
"travisci",
];

View File

@ -18,6 +18,10 @@ export class IntegrationsApiService extends AuthedApi {
return this.http.get("/api/v1/dimension/integrations/" + category + "/" + type).map(r => r.json()).toPromise();
}
public getIntegrationInRoom(category: string, type: string, roomId: string): Promise<FE_Integration> {
return this.authedGet("/api/v1/dimension/integrations/room/" + roomId + "/integrations/" + category + "/" + type).map(r => r.json()).toPromise();
}
public getWidget(type: string): Promise<FE_Widget> {
return this.getIntegration("widget", type).then(i => <FE_Widget>i);
}