Support adding/removing matrix.org's simple bots.

This adds #11
This commit is contained in:
turt2live 2017-05-27 17:45:07 -06:00
parent 22304d716c
commit 16e28019bc
24 changed files with 449 additions and 39 deletions

View File

@ -1,7 +1,30 @@
# matrix-dimension
# Dimension
[![Greenkeeper badge](https://badges.greenkeeper.io/turt2live/matrix-dimension.svg)](https://greenkeeper.io/) [![TravisCI badge](https://travis-ci.org/turt2live/matrix-dimension.svg?branch=master)](https://travis-ci.org/turt2live/matrix-dimension)
An alternative to Scalar for Riot.
An alternative integrations manager for [Riot](https://riot.im).
Join us on matrix: [#dimension:t2l.io](https://matrix.to/#/#dimension:t2l.io)
# Configuring Riot to use Dimension
Change the values in Riot's `config.json` as shown below. If you do not have a `config.json`, copy the `config.sample.json` from Riot.
```
"integrations_ui_url": "https://dimension.t2bot.io/riot",
"integrations_rest_url": "https://dimension.t2bot.io/api/v1/scalar",
```
The remaining settings should be tailored for your Riot deployment.
# Running your own
TODO
# Building
TODO
# Development
TODO

View File

@ -5,26 +5,31 @@ bots:
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"
- 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:
@ -34,6 +39,10 @@ web:
# 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

View File

@ -53,8 +53,10 @@ then we can expect a response object that looks like this:
An error response will always have the following structure under `response`:
```
{
"message": "Something went wrong",
"_error": <original Error object>
"error": {
"message": "Something went wrong",
"_error": <original Error object>
}
}
```

View File

@ -0,0 +1,27 @@
'use strict';
var dbm;
var type;
var seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function (options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};
exports.up = function (db) {
return db.addColumn('tokens', 'upstreamToken', {type: 'string', notNull: false}); // has to be nullable, despite our best intentions
};
exports.down = function (db) {
return db.removeColumn('tokens', 'upstreamToken');
};
exports._meta = {
"version": 1
};

View File

@ -42,6 +42,7 @@
"@ng-bootstrap/ng-bootstrap": "^1.0.0-alpha.22",
"@types/node": "^7.0.18",
"angular2-template-loader": "^0.6.2",
"angular2-toaster": "^4.0.0",
"angular2-ui-switch": "^1.2.0",
"autoprefixer": "^6.7.7",
"awesome-typescript-loader": "^3.1.2",

View File

@ -6,12 +6,17 @@ var bodyParser = require('body-parser');
var path = require("path");
var MatrixLiteClient = require("./matrix/MatrixLiteClient");
var randomString = require("random-string");
var ScalarClient = require("./scalar/ScalarClient.js");
var VectorScalarClient = require("./scalar/VectorScalarClient");
/**
* Primary entry point for Dimension
*/
class Dimension {
// TODO: Spread the app out into other classes
// eg: ScalarApi, DimensionApi, etc
/**
* Creates a new Dimension
* @param {DimensionStore} db the storage
@ -41,8 +46,10 @@ class Dimension {
});
this._app.post("/api/v1/scalar/register", this._scalarRegister.bind(this));
this._app.get("/api/v1/dimension/bots", this._getBots.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));
}
start() {
@ -50,6 +57,35 @@ 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}
var roomId = req.body.roomId;
var userId = req.body.userId;
var scalarToken = req.body.scalarToken;
if (!roomId || !userId || !scalarToken) {
res.status(400).send({error: "Missing room, user, or token"});
return;
}
var integrationName = null;
this._db.checkToken(scalarToken).then(() => {
for (var bot of config.bots) {
if (bot.mxid == userId) {
integrationName = bot.upstreamType;
break;
}
}
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}));
}
_getBots(req, res) {
res.setHeader("Content-Type", "application/json");
res.send(JSON.stringify(config.bots));
@ -57,29 +93,36 @@ class Dimension {
_checkScalarToken(req, res) {
var token = req.query.scalar_token;
if (!token) res.sendStatus(404);
if (!token) res.sendStatus(400);
else this._db.checkToken(token).then(() => {
res.sendStatus(200);
}, () => res.sendStatus(404));
}).catch(() => res.sendStatus(401));
}
_scalarRegister(req, res) {
res.setHeader("Content-Type", "application/json");
var tokenInfo = req.body;
if (!tokenInfo || !tokenInfo['access_token'] || !tokenInfo['token_type'] || !tokenInfo['matrix_server_name'] || !tokenInfo['expires_in']) {
res.status(400).send('Missing OpenID');
res.status(400).send({error: 'Missing OpenID'});
return;
}
var client = new MatrixLiteClient(tokenInfo);
var scalarToken = randomString({length: 25});
var userId;
client.getSelfMxid().then(mxid => {
return this._db.createToken(mxid, tokenInfo, scalarToken);
userId = mxid;
if (!mxid) throw new Error("Token does not resolve to a matrix user");
return ScalarClient.register(tokenInfo);
}).then(upstreamToken => {
return this._db.createToken(userId, tokenInfo, scalarToken, upstreamToken);
}).then(() => {
res.setHeader("Content-Type", "application/json");
res.send(JSON.stringify({scalar_token: scalarToken}));
}, err => {
throw err;
//res.status(500).send(err.message);
res.send({scalar_token: scalarToken});
}).catch(err => {
log.error("Dimension", err);
res.status(500).send({error: err.message});
});
}
}

View File

@ -43,6 +43,7 @@ class MatrixLiteClient {
return new Promise((resolve, reject) => {
request(params, (err, response, body) => {
if (err) {
log.error("MatrixLiteClient", err);
reject(err);
} else resolve(response, body);
});

View File

@ -0,0 +1,55 @@
var request = require('request');
var log = require("../util/LogService");
var config = require("config");
/**
* Represents a scalar client
*/
class ScalarClient {
/**
* Creates a new Scalar client
*/
constructor() {
}
/**
* Registers for a scalar token
* @param {OpenID} openId the open ID to register
* @returns {Promise<string>} resolves to a scalar token
*/
register(openId) {
return this._do("POST", "/register", null, openId).then((response, body) => {
if (response.statusCode !== 200) {
log.error("ScalarClient", response.body);
return Promise.reject(response.body);
}
return response.body['scalar_token'];
});
}
_do(method, endpoint, qs = null, body = null) {
var url = config.scalar.upstreamRestUrl + endpoint;
log.verbose("ScalarClient", "Performing request: " + url);
var params = {
url: url,
method: method,
json: body,
qs: qs
};
return new Promise((resolve, reject) => {
request(params, (err, response, body) => {
if (err) {
log.error("ScalarClient", err);
reject(err);
} else resolve(response, body);
});
});
}
}
module.exports = new ScalarClient();

View File

@ -0,0 +1,69 @@
var request = require('request');
var log = require("../util/LogService");
var config = require("config");
/**
* Represents a scalar client for vector.im
*/
class VectorScalarClient {
/**
* Creates a new vector.im Scalar client
*/
constructor() {
}
/**
* Registers for a scalar token
* @param {OpenID} openId the open ID to register
* @returns {Promise<string>} resolves to a scalar token
*/
register(openId) {
return this._do("POST", "/register", null, openId).then((response, body) => {
var json = JSON.parse(response.body);
return json['scalar_token'];
});
}
/**
* Removes a scalar integration
* @param {string} type the type of integration to remove
* @param {string} roomId the room ID to remove it from
* @param {string} scalarToken the upstream scalar token
* @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) => {
if (response.statusCode !== 200) {
log.error("VectorScalarClient", response.body);
return Promise.reject(response.body);
}
// no success processing
});
}
_do(method, endpoint, qs = null, body = null) {
var url = config.scalar.upstreamRestUrl + endpoint;
log.verbose("VectorScalarClient", "Performing request: " + url);
var params = {
url: url,
method: method,
json: body,
qs: qs
};
return new Promise((resolve, reject) => {
request(params, (err, response, body) => {
if (err) {
log.error("VectorScalarClient", err);
reject(err);
} else resolve(response, body);
});
});
}
}
module.exports = new VectorScalarClient();

View File

@ -65,14 +65,16 @@ class DimensionStore {
* @param {string} mxid the matrix user id
* @param {OpenID} openId the open ID
* @param {string} scalarToken the token associated with the user
* @param {string} upstreamToken the upstream scalar token
* @returns {Promise<>} resolves when complete
*/
createToken(mxid, openId, scalarToken) {
createToken(mxid, openId, scalarToken, upstreamToken) {
return this.__Tokens.create({
matrixUserId: mxid,
matrixServerName: openId.matrix_server_name,
matrixAccessToken: openId.access_token,
scalarToken: scalarToken,
upstreamToken: upstreamToken,
expires: moment().add(openId.expires_in, 'seconds').toDate()
});
}
@ -89,6 +91,18 @@ class DimensionStore {
return Promise.resolve();
});
}
/**
* Gets the upstream token for a given scalar token
* @param {string} scalarToken the scalar token to lookup
* @returns {Promise<string>} resolves to the upstream token, or null if not found
*/
getUpstreamToken(scalarToken) {
return this.__Tokens.find({where: {scalarToken: scalarToken}}).then(token => {
if (!token) return null;
return token.upstreamToken;
});
}
}
module.exports = DimensionStore;

View File

@ -27,6 +27,11 @@ module.exports = function (sequelize, DataTypes) {
allowNull: false,
field: 'scalarToken'
},
upstreamToken: {
type: DataTypes.STRING,
allowNull: false,
field: 'upstreamToken'
},
expires: {
type: DataTypes.TIME,
allowNull: false,

View File

@ -1,6 +1,7 @@
<header>
</header>
<main>
<toaster-container></toaster-container>
<router-outlet></router-outlet>
</main>
<footer>

View File

@ -11,6 +11,9 @@ 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";
@NgModule({
imports: [
@ -20,6 +23,8 @@ import { UiSwitchModule } from "angular2-ui-switch";
routing,
NgbModule.forRoot(),
UiSwitchModule,
ToasterModule,
BrowserAnimationsModule,
],
declarations: [
AppComponent,
@ -31,6 +36,7 @@ import { UiSwitchModule } from "angular2-ui-switch";
],
providers: [
ApiService,
ScalarService,
// Vendor
],

View File

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

View File

@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core";
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { Bot } from "../shared/models/bot";
@Component({
@ -9,15 +9,13 @@ import { Bot } from "../shared/models/bot";
export class BotComponent {
@Input() bot: Bot;
public updating = false;
public htmlAbout: string;
@Output() updated: EventEmitter<any> = new EventEmitter();
constructor() {
}
public update(): void {
this.updating = true;
setTimeout(() => this.updating = false, Math.random() * 15000);
this.bot.isEnabled = !this.bot.isEnabled;
this.updated.emit();
}
}

View File

@ -1,10 +1,13 @@
<div *ngIf="error">
<p class="text-danger">{{ error }}</p>
</div>
<div *ngIf="!error">
<div *ngIf="loading && !error">
<p><i class="fa fa-circle-o-notch fa-spin"></i> Loading...</p>
</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>
<div class="integration-container">
<my-bot *ngFor="let bot of bots" [bot]="bot"></my-bot>
<my-bot *ngFor="let bot of bots" [bot]="bot" (updated)="updateBot(bot)"></my-bot>
</div>
</div>

View File

@ -2,6 +2,8 @@ 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";
@Component({
selector: 'my-riot',
@ -12,21 +14,71 @@ export class RiotComponent {
public error: string;
public bots: Bot[] = [];
public loading = true;
public roomId: string;
constructor(private activatedRoute: ActivatedRoute, private api: ApiService) {
private scalarToken: string;
constructor(private activatedRoute: ActivatedRoute, private api: ApiService, private scalar: ScalarService, private toaster:ToasterService) {
let params: any = this.activatedRoute.snapshot.queryParams;
if (!params.scalar_token || !params.room_id) this.error = "Missing scalar token or room ID";
else this.api.checkScalarToken(params.scalar_token).then(isValid => {
if (isValid) this.init();
else this.error = "Invalid scalar token";
});
else {
this.roomId = params.room_id;
this.scalarToken = params.scalar_token;
this.api.checkScalarToken(params.scalar_token).then(isValid => {
if (isValid) this.init();
else this.error = "Invalid scalar token";
}).catch(err => {
this.error = "Unable to communicate with Dimension";
console.error(err);
});
}
}
private init() {
this.api.getBots().then(bots => {
this.bots = bots;
bots.map(b => b.isEnabled = Math.random() > 0.75);
let promises = bots.map(b => this.updateBotState(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;
});
}
public updateBot(bot: Bot) {
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);
promise
.then(() => this.toaster.pop("success", bot.name + " invited to the room"))
.catch(err => {
var errorMessage = "Could not update bot status";
if (err.json) {
errorMessage = err.json().error;
} else errorMessage = err.response.error.message;
bot.isEnabled = !bot.isEnabled;
this.toaster.pop("error", errorMessage);
});
}
}

View File

@ -16,4 +16,9 @@ export class ApiService {
return this.http.get("/api/v1/dimension/bots")
.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})
.map(res => res.json()).toPromise();
}
}

View File

@ -1,5 +0,0 @@
// Services
export * from './api.service';
// Models
export * from './models/bot';

View File

@ -1,7 +1,8 @@
export class Bot {
export interface Bot {
mxid: string;
name: string;
avatar: string;
about: string; // nullable
isEnabled: boolean;
isBroken: boolean;
}

View File

@ -0,0 +1,27 @@
export interface ScalarResponse {
action: string;
}
export interface ScalarRoomResponse extends ScalarResponse {
room_id: string;
}
export interface ScalarUserResponse extends ScalarRoomResponse {
user_id: string;
}
export interface ScalarErrorResponse extends ScalarResponse {
response: {error: {message: string, _error: Error}};
}
export interface ScalarSuccessResponse extends ScalarResponse {
response: {success: boolean};
}
export interface MembershipStateResponse extends ScalarUserResponse {
response: {
membership: string;
avatar_url: string;
displayname: string;
};
}

View File

@ -0,0 +1,68 @@
import { Injectable } from "@angular/core";
import * as randomString from "random-string";
import { MembershipStateResponse, ScalarSuccessResponse } from "./models/scalar_responses";
@Injectable()
export class ScalarService {
private static actionMap: {[key: string]: {resolve: (obj: any) => void, reject: (obj: any) => void}} = {};
public static getAndRemoveActionHandler(requestKey: string): {resolve: (obj: any) => void, reject: (obj: any) => void} {
let handler = ScalarService.actionMap[requestKey];
ScalarService.actionMap[requestKey] = null;
return handler;
}
constructor() {
}
public inviteUser(roomId: string, userId): Promise<ScalarSuccessResponse> {
return this.callAction("invite", {
room_id: roomId,
user_id: userId
});
}
public getMembershipState(roomId: string, userId: string): Promise<MembershipStateResponse> {
return this.callAction("membership_state", {
room_id: roomId,
user_id: userId
});
}
private callAction(action, payload) {
let requestKey = randomString({length: 20});
return new Promise((resolve, reject) => {
if (!window.opener) {
// Mimic an error response from scalar
reject({response: {error: {message: "No window.opener", _error: new Error("No window.opener")}}});
return;
}
ScalarService.actionMap[requestKey] = {
resolve: resolve,
reject: reject
};
let request = JSON.parse(JSON.stringify(payload));
request["request_id"] = requestKey;
request["action"] = action;
window.opener.postMessage(request, "*");
});
}
}
// Register the event listener here to ensure it gets created
window.addEventListener("message", event => {
if (!event.data) return;
let requestKey = event.data["request_id"];
if (!requestKey) return;
let action = ScalarService.getAndRemoveActionHandler(requestKey);
if (!action) return;
if (event.data.response && event.data.response.error) action.reject(event.data);
else action.resolve(event.data);
});

View File

@ -19,13 +19,16 @@ if (document.readyState === 'complete') {
document.addEventListener('DOMContentLoaded', main);
}
(<any>String.prototype).hashCode = function() {
(<any>String.prototype).hashCode = function () {
let hash = 0, i, chr;
if (this.length === 0) return hash;
for (i = 0; i < this.length; i++) {
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};
// HACK: Work around .opener not being available
if (!window.opener && window.parent) window.opener = window.parent;

View File

@ -1,4 +1,6 @@
// styles in src/style directory are applied to the whole page
@import '../../node_modules/angular2-toaster/toaster';
body {
background: #ddd !important;
margin: 0;