Change up bot structure to support hosted bots. Adds #12

This commit is contained in:
turt2live 2017-05-28 00:35:40 -06:00
parent 01ed07479e
commit 35559c9373
28 changed files with 379 additions and 129 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@ config/development.yaml
config/production.yaml
db/*.db
start.sh
config/integrations/*_development.yaml
config/integrations/*_production.yaml
# Logs
logs

8
app.js
View File

@ -1,10 +1,18 @@
var log = require("./src/util/LogService");
var Dimension = require("./src/Dimension");
var DimensionStore = require("./src/storage/DimensionStore");
var DemoBot = require("./src/matrix/DemoBot");
var config = require("config");
log.info("app", "Bootstrapping Dimension...");
var db = new DimensionStore();
db.prepare().then(() => {
var app = new Dimension(db);
app.start();
if (config.get("demobot.enabled")) {
log.info("app", "Demo bot enabled - starting up");
var bot = new DemoBot(config.get("demobot.homeserverUrl"), config.get("demobot.userId"), config.get("demobot.accessToken"));
bot.start();
}
}, err => log.error("app", err)).catch(err => log.error("app", err));

View File

@ -1,48 +1,8 @@
# Configuration for the bots this Dimension supports
bots:
# Giphy (from matrix.org)
- mxid: "@neb_giphy:matrix.org"
name: "Giphy"
avatar: "/img/avatars/giphy.png"
about: "Use `!giphy query` to find an animated GIF on demand"
upstreamType: "giphy"
# Guggy (from matrix.org)
- mxid: "@_neb_guggy:matrix.org"
name: "Guggy"
avatar: "/img/avatars/guggy.png"
about: "Use `!guggy sentence` to create an animated GIF from a sentence"
upstreamType: "guggy"
# Imgur (from matrix.org)
- mxid: "@_neb_imgur:matrix.org"
name: "Imgur"
avatar: "/img/avatars/imgur.png"
about: "Use `!imgur query` to find an image from Imgur"
upstreamType: "imgur"
# Wikipedia (from matrix.org)
- mxid: "@_neb_wikipedia:matrix.org"
name: "Wikipedia"
avatar: "/img/avatars/wikipedia.png"
about: "Use `!wikipedia query` to find something from Wikipedia"
upstreamType: "wikipedia"
# Google (from matrix.org)
- mxid: "@_neb_google:matrix.org"
name: "Google"
avatar: "/img/avatars/google.png"
about: "Use `!google image query` to find an image from Google"
upstreamType: "google"
# The web settings for the service (API and UI)
web:
# The port to run the webserver on
port: 8184
# The address to bind to (0.0.0.0 for all interfaces)
address: '0.0.0.0'
# Upstream scalar configuration. This should almost never change.
scalar:
upstreamRestUrl: "https://scalar.vector.im/api"
# Settings for controlling how logging works
logging:
file: logs/dimension.log
@ -51,4 +11,15 @@ logging:
fileLevel: verbose
rotate:
size: 52428800 # bytes, default is 50mb
count: 5
count: 5
# Demo bot configuration. Used purely to show how to configure a self-hosted bot in Dimension
demobot:
enabled: false
userId: "@dimension:t2bot.io"
homeserverUrl: "https://t2bot.io"
accessToken: "something"
# Upstream configuration. This should almost never change.
upstreams:
vector: "https://scalar.vector.im/api"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@dimension:t2bot.io"
name: "Demo Bot"
about: "A bot that has no functionality. This is just a demonstration on the config."
avatar: "/img/avatars/demobot.png"
hosted:
homeserverUrl: "https://t2bot.io"
accessToken: "your_matrix_access_token_here"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@neb_giphy:matrix.org"
name: "Giphy"
about: "Use `!giphy query` to find an animated GIF on demand"
avatar: "/img/avatars/giphy.png"
upstream:
type: "vector"
id: "giphy"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@_neb_google:matrix.org"
name: "Google"
about: "Use `!google image query` to find an image from Google"
avatar: "/img/avatars/google.png"
upstream:
type: "vector"
id: "google"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@_neb_guggy:matrix.org"
name: "Guggy"
about: "Use `!guggy sentence` to create an animated GIF from a sentence"
avatar: "/img/avatars/guggy.png"
upstream:
type: "vector"
id: "guggy"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@_neb_imgur:matrix.org"
name: "Imgur"
about: "Use `!imgur query` to find an image from Imgur"
avatar: "/img/avatars/imgur.png"
upstream:
type: "vector"
id: "imgur"

View File

@ -0,0 +1,9 @@
type: "bot"
enabled: true
userId: "@_neb_wikipedia:matrix.org"
name: "Wikipedia"
about: "Use `!wikipedia query` to find something from Wikipedia"
avatar: "/img/avatars/wikipedia.png"
upstream:
type: "vector"
id: "wikipedia"

View File

@ -21,6 +21,8 @@
"db-migrate-sqlite3": "^0.2.1",
"express": "^4.15.2",
"js-yaml": "^3.8.2",
"lodash": "^4.17.4",
"matrix-js-sdk": "^0.7.8",
"moment": "^2.18.1",
"random-string": "^0.2.0",
"request": "^2.81.0",

View File

@ -8,6 +8,10 @@ var MatrixLiteClient = require("./matrix/MatrixLiteClient");
var randomString = require("random-string");
var ScalarClient = require("./scalar/ScalarClient.js");
var VectorScalarClient = require("./scalar/VectorScalarClient");
var integrations = require("./integration");
var _ = require("lodash");
var UpstreamIntegration = require("./integration/UpstreamIntegration");
var HostedIntegration = require("./integration/HostedIntegration");
/**
* Primary entry point for Dimension
@ -48,8 +52,8 @@ class Dimension {
this._app.post("/api/v1/scalar/register", this._scalarRegister.bind(this));
this._app.get("/api/v1/scalar/checkToken", this._checkScalarToken.bind(this));
this._app.get("/api/v1/dimension/bots", this._getBots.bind(this));
this._app.post("/api/v1/dimension/kick", this._kickUser.bind(this));
this._app.get("/api/v1/dimension/integrations", this._getIntegrations.bind(this));
this._app.post("/api/v1/dimension/removeIntegration", this._removeIntegration.bind(this));
}
start() {
@ -57,8 +61,7 @@ class Dimension {
log.info("Dimension", "API and UI listening on " + config.get("web.address") + ":" + config.get("web.port"));
}
_kickUser(req, res) {
// {roomId: roomId, userId: userId, scalarToken: scalarToken}
_removeIntegration(req, res) {
var roomId = req.body.roomId;
var userId = req.body.userId;
var scalarToken = req.body.scalarToken;
@ -68,27 +71,32 @@ class Dimension {
return;
}
var integrationName = null;
this._db.checkToken(scalarToken).then(() => {
for (var bot of config.bots) {
if (bot.mxid == userId) {
integrationName = bot.upstreamType;
break;
}
}
var integrationConfig = integrations.byUserId[userId];
if (!integrationConfig) {
res.status(400).send({error: "Unknown integration"});
return;
}
return this._db.getUpstreamToken(scalarToken);
}).then(upstreamToken => {
if (!upstreamToken || !integrationName) {
res.status(400).send({error: "Missing token or integration name"});
return Promise.resolve();
} else return VectorScalarClient.removeIntegration(integrationName, roomId, upstreamToken);
}).then(() => res.status(200).send({success: true})).catch(err => res.status(500).send({error: err.message}));
this._db.checkToken(scalarToken).then(() => {
if (integrationConfig.upstream) {
return this._db.getUpstreamToken(scalarToken).then(upstreamToken => new UpstreamIntegration(integrationConfig, upstreamToken));
} else return new HostedIntegration(integrationConfig);
}).then(integration => integration.leaveRoom(roomId)).then(() => {
res.status(200).send({success: true});
}).catch(err => res.status(500).send({error: err.message}));
}
_getBots(req, res) {
_getIntegrations(req, res) {
res.setHeader("Content-Type", "application/json");
res.send(JSON.stringify(config.bots));
var results = _.map(integrations.all, i => {
var integration = JSON.parse(JSON.stringify(i));
integration.upstream = undefined;
integration.hosted = undefined;
return integration;
});
res.send(results);
}
_checkScalarToken(req, res) {

View File

@ -0,0 +1,35 @@
var sdk = require("matrix-js-sdk");
var log = require("../util/LogService");
var StubbedIntegration = require("./StubbedIntegration");
/**
* Represents an integration hosted on a known homeserver
*/
class HostedIntegration extends StubbedIntegration {
/**
* Creates a new hosted integration
* @param integrationSettings the integration settings
*/
constructor(integrationSettings) {
super();
this._settings = integrationSettings;
this._client = sdk.createClient({
baseUrl: this._settings.hosted.homeserverUrl,
accessToken: this._settings.hosted.accessToken,
userId: this._settings.userId,
});
}
/**
* Leaves a given Matrix room
* @param {string} roomId the room to leave
* @returns {Promise<>} resolves when completed
*/
leaveRoom(roomId) {
log.info("HostedIntegration", "Removing " + this._settings.userId + " from " + roomId);
return this._client.leave(roomId);
}
}
module.exports = HostedIntegration;

View File

@ -0,0 +1,7 @@
class StubbedIntegration {
leaveRoom(roomId) {
throw new Error("Not implemented");
}
}
module.exports = StubbedIntegration;

View File

@ -0,0 +1,33 @@
var VectorScalarClient = require("../scalar/VectorScalarClient");
var log = require("../util/LogService");
var StubbedIntegration = require("./StubbedIntegration");
/**
* An integration that is handled by an upstream Scalar instance
*/
class UpstreamIntegration extends StubbedIntegration {
/**
* Creates a new hosted integration
* @param integrationSettings the integration settings
* @param {string} upstreamToken the upstream scalar token
*/
constructor(integrationSettings, upstreamToken) {
super();
this._settings = integrationSettings;
this._upstreamToken = upstreamToken;
if (this._settings.upstream.type !== "vector") throw new Error("Unknown upstream type: " + this._settings.upstream.type);
}
/**
* Leaves a given Matrix room
* @param {string} roomId the room to leave
* @returns {Promise<>} resolves when completed
*/
leaveRoom(roomId) {
log.info("UpstreamIntegration", "Removing " + this._settings.userId + " from " + roomId);
return VectorScalarClient.removeIntegration(this._settings.upstream.id, roomId, this._upstreamToken);
}
}
module.exports = UpstreamIntegration;

55
src/integration/index.js Normal file
View File

@ -0,0 +1,55 @@
var config = require("config");
var log = require("../util/LogService");
var fs = require("fs");
var path = require("path");
var _ = require("lodash");
log.info("Integrations", "Discovering integrations");
var searchPath = path.join(process.cwd(), "config", "integrations");
var files = _.filter(fs.readdirSync(searchPath), f => !fs.statSync(path.join(searchPath, f)).isDirectory() && f.endsWith(".yaml"));
var currentEnv = config.util.initParam("NODE_ENV", "development");
if (currentEnv !== "development" && currentEnv !== "production")
throw new Error("Unknown node environment: " + currentEnv);
var configs = {};
for (var file of files) {
if (file.endsWith("_development.yaml") || file.endsWith("_production.yaml")) {
if (!file.endsWith("_" + currentEnv + ".yaml")) continue;
var fileName = file.replace("_development.yaml", "").replace("_production.yaml", "") + ".yaml";
if (!configs[fileName]) configs[fileName] = {};
configs[fileName]["alt"] = config.util.parseFile(path.join(searchPath, file));
} else {
if (!configs[file]) configs[file] = {};
configs[file]["defaults"] = config.util.parseFile(path.join(searchPath, file));
}
}
var keys = _.keys(configs);
log.info("Integrations", "Discovered " + keys.length + " integrations. Parsing definitions...");
var linear = [];
var byUserId = {};
for (var key of keys) {
log.info("Integrations", "Preparing " + key);
var merged = config.util.extendDeep(configs[key].defaults, configs[key].alt);
if (!merged['enabled']) {
log.warn("Integrations", "Integration " + key + " is not enabled - skipping");
continue;
}
linear.push(merged);
if (merged['userId'])
byUserId[merged['userId']] = merged;
}
log.info("Integrations", "Loaded " + linear.length + " integrations");
module.exports = {
all: linear,
byUserId: byUserId
};

66
src/matrix/DemoBot.js Normal file
View File

@ -0,0 +1,66 @@
var sdk = require("matrix-js-sdk");
var log = require("../util/LogService");
/**
* Dimension demo bot. Doesn't do anything except show how to add a self-hosted bot to Dimension
*/
class DemoBot {
constructor(homeserverUrl, userId, accessToken) {
this._rooms = [];
log.info("DemoBot", "Constructing bot as " + userId);
this._client = sdk.createClient({
baseUrl: homeserverUrl,
accessToken: accessToken,
userId: userId,
});
this._client.on('event', event => {
if (event.getType() !== "m.room.member") return;
if (event.getStateKey() != this._client.credentials.userId) return;
if (event.getContent().membership === 'invite') {
if (this._rooms.indexOf(event.getRoomId()) !== -1) return;
log.info("DemoBot", "Joining " + event.getRoomId());
this._client.joinRoom(event.getRoomId()).then(() => {
this._recalculateRooms();
this._client.sendMessage(event.getRoomId(), {
msgtype: "m.notice",
body: "Hello! I'm a small bot that acts as an example for how to set up your own bot on Dimension."
});
});
} else this._recalculateRooms();
});
}
start() {
log.info("DemoBot", "Starting bot");
this._client.startClient();
this._client.on('sync', state => {
if (state == 'PREPARED') this._recalculateRooms();
});
}
_recalculateRooms() {
var rooms = this._client.getRooms();
this._rooms = [];
for (var room of rooms) {
var me = room.getMember(this._client.credentials.userId);
if (!me) continue;
if (me.membership == "invite") {
this._client.joinRoom(room.roomId);
continue;
}
if (me.membership != "join") continue;
this._rooms.push(room.roomId);
}
log.verbose("DemoBot", "Currently in " + this._rooms.length + " rooms");
}
}
module.exports = DemoBot;

View File

@ -29,8 +29,10 @@ class ScalarClient {
});
}
// TODO: Merge this, VectorScalarClient, and MatrixLiteClient into a base class
_do(method, endpoint, qs = null, body = null) {
var url = config.scalar.upstreamRestUrl + endpoint;
// TODO: Generify URL
var url = config.get("upstreams.vector") + endpoint;
log.verbose("ScalarClient", "Performing request: " + url);

View File

@ -33,7 +33,10 @@ class VectorScalarClient {
* @return {Promise<>} resolves when complete
*/
removeIntegration(type, roomId, scalarToken) {
return this._do("POST", "/removeIntegration", {scalar_token: scalarToken}, {type: type, room_id: roomId}).then((response, body) => {
return this._do("POST", "/removeIntegration", {scalar_token: scalarToken}, {
type: type,
room_id: roomId
}).then((response, body) => {
if (response.statusCode !== 200) {
log.error("VectorScalarClient", response.body);
return Promise.reject(response.body);
@ -44,7 +47,7 @@ class VectorScalarClient {
}
_do(method, endpoint, qs = null, body = null) {
var url = config.scalar.upstreamRestUrl + endpoint;
var url = config.get("upstreams.vector") + endpoint;
log.verbose("VectorScalarClient", "Performing request: " + url);

View File

@ -9,11 +9,11 @@ import { removeNgStyles, createNewHosts } from "@angularclass/hmr";
import { NgbModule } from "@ng-bootstrap/ng-bootstrap";
import { RiotComponent } from "./riot/riot.component";
import { ApiService } from "./shared/api.service";
import { BotComponent } from "./bot/bot.component";
import { UiSwitchModule } from "angular2-ui-switch";
import { ScalarService } from "./shared/scalar.service";
import { ToasterModule } from "angular2-toaster";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { IntegrationComponent } from "./integration/integration.component";
@NgModule({
imports: [
@ -30,7 +30,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
AppComponent,
HomeComponent,
RiotComponent,
BotComponent,
IntegrationComponent,
// Vendor
],

View File

@ -1,21 +0,0 @@
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Bot } from "../shared/models/bot";
@Component({
selector: 'my-bot',
templateUrl: './bot.component.html',
styleUrls: ['./bot.component.scss'],
})
export class BotComponent {
@Input() bot: Bot;
@Output() updated: EventEmitter<any> = new EventEmitter();
constructor() {
}
public update(): void {
this.bot.isEnabled = !this.bot.isEnabled;
this.updated.emit();
}
}

View File

@ -1,13 +1,13 @@
<div class="bot">
<img [src]="bot.avatar" class="avatar">
<img [src]="integration.avatar" class="avatar">
<div class="title">
<b>{{ bot.name }}</b>
<b>{{ integration.name }}</b>
<div style="display: flex;">
<div class="switch">
<ui-switch [checked]="bot.isEnabled" size="small" [disabled]="bot.isBroken" (change)="update()"></ui-switch>
<ui-switch [checked]="integration.isEnabled" size="small" [disabled]="integration.isBroken" (change)="update()"></ui-switch>
</div>
<div class="toolbar">
<i class="fa fa-question-circle text-info" ngbTooltip="{{bot.about}}" *ngIf="bot.about"></i>
<i class="fa fa-question-circle text-info" ngbTooltip="{{integration.about}}" *ngIf="integration.about"></i>
</div>
</div>
</div>

View File

@ -0,0 +1,21 @@
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Integration } from "../shared/models/integration";
@Component({
selector: 'my-integration',
templateUrl: './integration.component.html',
styleUrls: ['./integration.component.scss'],
})
export class IntegrationComponent {
@Input() integration: Integration;
@Output() updated: EventEmitter<any> = new EventEmitter();
constructor() {
}
public update(): void {
this.integration.isEnabled = !this.integration.isEnabled;
this.updated.emit();
}
}

View File

@ -9,7 +9,7 @@
<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>
<div class="integration-container">
<my-bot *ngFor="let bot of bots" [bot]="bot" (updated)="updateBot(bot)"></my-bot>
<my-integration *ngFor="let integration of integrations" [integration]="integration" (updated)="updateIntegration(integration)"></my-integration>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ApiService } from "../shared/api.service";
import { Bot } from "../shared/models/bot";
import { ScalarService } from "../shared/scalar.service";
import { ToasterService } from "angular2-toaster";
import { Integration } from "../shared/models/integration";
@Component({
selector: 'my-riot',
@ -13,7 +13,7 @@ import { ToasterService } from "angular2-toaster";
export class RiotComponent {
public error: string;
public bots: Bot[] = [];
public integrations: Integration[] = [];
public loading = true;
public roomId: string;
@ -40,47 +40,50 @@ export class RiotComponent {
}
private init() {
this.api.getBots().then(bots => {
this.bots = bots;
let promises = bots.map(b => this.updateBotState(b));
this.api.getIntegrations().then(integrations => {
this.integrations = integrations;
let promises = integrations.map(b => this.updateIntegrationState(b));
return Promise.all(promises);
}).then(() => this.loading = false);
}
private updateBotState(bot: Bot) {
return this.scalar.getMembershipState(this.roomId, bot.mxid).then(payload => {
bot.isBroken = false;
if (!payload.response) {
bot.isEnabled = false;
return;
}
bot.isEnabled = (payload.response.membership === 'join' || payload.response.membership === 'invite');
}, (error) => {
console.error(error);
bot.isEnabled = false;
bot.isBroken = true;
}).then(() => this.loading = false).catch(err => {
this.error = "Unable to communicate with Dimension";
console.error(err);
});
}
public updateBot(bot: Bot) {
private updateIntegrationState(integration: Integration) {
return this.scalar.getMembershipState(this.roomId, integration.userId).then(payload => {
integration.isBroken = false;
if (!payload.response) {
integration.isEnabled = false;
return;
}
integration.isEnabled = (payload.response.membership === 'join' || payload.response.membership === 'invite');
}, (error) => {
console.error(error);
integration.isEnabled = false;
integration.isBroken = true;
});
}
public updateIntegration(integration: Integration) {
let promise = null;
if (!bot.isEnabled) {
promise = this.api.kickUser(this.roomId, bot.mxid, this.scalarToken);
} else promise = this.scalar.inviteUser(this.roomId, bot.mxid);
if (!integration.isEnabled) {
promise = this.api.removeIntegration(this.roomId, integration.userId, this.scalarToken);
} else promise = this.scalar.inviteUser(this.roomId, integration.userId);
promise
.then(() => this.toaster.pop("success", bot.name + " invited to the room"))
.then(() => this.toaster.pop("success", integration.name + " invited to the room"))
.catch(err => {
let errorMessage = "Could not update bot status";
let errorMessage = "Could not update integration status";
if (err.json) {
errorMessage = err.json().error;
} else errorMessage = err.response.error.message;
bot.isEnabled = !bot.isEnabled;
integration.isEnabled = !integration.isEnabled;
this.toaster.pop("error", errorMessage);
});
}

View File

@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
import { Http } from "@angular/http";
import { Bot } from "./models/bot";
import { Integration } from "./models/integration";
@Injectable()
export class ApiService {
@ -12,13 +12,13 @@ export class ApiService {
.map(res => res.status === 200).toPromise();
}
getBots(): Promise<Bot[]> {
return this.http.get("/api/v1/dimension/bots")
getIntegrations(): Promise<Integration[]> {
return this.http.get("/api/v1/dimension/integrations")
.map(res => res.json()).toPromise();
}
kickUser(roomId: string, userId: string, scalarToken: string): Promise<any> {
return this.http.post("/api/v1/dimension/kick", {roomId: roomId, userId: userId, scalarToken: scalarToken})
removeIntegration(roomId: string, userId: string, scalarToken: string): Promise<any> {
return this.http.post("/api/v1/dimension/removeIntegration", {roomId: roomId, userId: userId, scalarToken: scalarToken})
.map(res => res.json()).toPromise();
}
}

View File

@ -1,5 +1,6 @@
export interface Bot {
mxid: string;
export interface Integration {
type: string;
userId: string;
name: string;
avatar: string;
about: string; // nullable

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB