diff --git a/config/integrations/rssbot.yaml b/config/integrations/rssbot.yaml new file mode 100644 index 0000000..194ce8b --- /dev/null +++ b/config/integrations/rssbot.yaml @@ -0,0 +1,9 @@ +type: "complex-bot" +integrationType: "rss" +enabled: true +name: "RSS Bot" +about: "Tracks any Atom/RSS feed and sends new items into this room" +avatar: "/img/avatars/rssbot.png" +upstream: + type: "vector" + id: "rssbot" \ No newline at end of file diff --git a/docs/scalar_server_api.md b/docs/scalar_server_api.md index 3318e84..2b0f88f 100644 --- a/docs/scalar_server_api.md +++ b/docs/scalar_server_api.md @@ -4,6 +4,67 @@ Scalar has a server-side component to assist in managing integrations. The known None of these are officially documented, and are subject to change. +## POST `/api/integrations?scalar_token=...` + +**Body**: +``` +{ + "RoomID": "!JmvocvDuPTYUfuvKgs:t2l.io" +} +``` +*Note*: Case difference appears to be intentional. + +**Response**: +``` +{ + "integrations": [{ + "type": "rssbot", + "user_id": "@travis:t2l.io", + "config": { + "feeds": { + "https://ci.t2l.io/view/all/rssAll": { + "poll_interval_mins": 0, + "is_failing": false, + "last_updated_ts_secs": 1495995601, + "rooms": ["!JmvocvDuPTYUfuvKgs:t2l.io"] + } + } + }, + "self": false + },{ + "type": "rssbot", + "user_id": "@travis:tang.ents.ca", + "config": { + "feeds": { + "https://ci.t2l.io/job/java-simple-eventemitter/rssAll": { + "poll_interval_mins": 0, + "is_failing": false, + "last_updated_ts_secs": 1495995618, + "rooms": ["!JmvocvDuPTYUfuvKgs:t2l.io"] + } + } + }, + "self": true + },{ + "type": "travis-ci", + "user_id": "@travis:t2l.io", + "config": { + "webhook_url": "https://scalar.vector.im/api/neb/services/hooks/some_long_string", + "rooms": { + "!JmvocvDuPTYUfuvKgs:t2l.io": { + "repos": { + "turt2live/matrix-dimension": { + "template": "%{repository}#%{build_number} (%{branch} - %{commit} : %{author}): %{message}\n Change view : %{compare_url}\n Build details : %{build_url}\n" + } + } + } + } + }, + "self": false + }] +} +``` + ## POST `/api/integrations/{type}?scalar_token=...` **Params**: diff --git a/src/Dimension.js b/src/Dimension.js index e59745b..b9711c1 100644 --- a/src/Dimension.js +++ b/src/Dimension.js @@ -12,6 +12,7 @@ var integrations = require("./integration"); var _ = require("lodash"); var UpstreamIntegration = require("./integration/UpstreamIntegration"); var HostedIntegration = require("./integration/HostedIntegration"); +var IntegrationImpl = require("./integration/impl"); /** * Primary entry point for Dimension @@ -52,7 +53,7 @@ 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/integrations", this._getIntegrations.bind(this)); + this._app.get("/api/v1/dimension/integrations/:roomId", this._getIntegrations.bind(this)); this._app.post("/api/v1/dimension/removeIntegration", this._removeIntegration.bind(this)); } @@ -89,14 +90,46 @@ class Dimension { _getIntegrations(req, res) { res.setHeader("Content-Type", "application/json"); - var results = _.map(integrations.all, i => { - var integration = JSON.parse(JSON.stringify(i)); - integration.upstream = undefined; - integration.hosted = undefined; - return integration; - }); + var scalarToken = req.query.scalar_token; + this._db.checkToken(scalarToken).then(() => { + var roomId = req.params.roomId; + if (!roomId) { + res.status(400).send({error: 'Missing room ID'}); + return; + } - res.send(results); + var results = _.map(integrations.all, i => JSON.parse(JSON.stringify(i))); + + var promises = []; + _.forEach(results, i => { + if (IntegrationImpl[i.type]) { + var confs = IntegrationImpl[i.type]; + if (confs[i.integrationType]) { + log.info("Dimension", "Using special configuration for " + i.type + " (" + i.integrationType + ")"); + + promises.push(confs[i.integrationType](this._db, i, roomId, scalarToken).then(integration => { + return integration.getUserId().then(userId=> { + i.userId = userId; + return integration.getState(); + }).then(state=> { + for (var key in state) { + i[key] = state[key]; + } + }); + })) + } else log.verbose("Dimension", "No special configuration needs for " + i.type + " (" + i.integrationType + ")"); + } else log.verbose("Dimension", "No special implementation type for " + i.type); + }); + + Promise.all(promises).then(() => res.send(_.map(results, integration => { + integration.upstream = undefined; + integration.hosted = undefined; + return integration; + }))); + }).catch(err => { + log.error("Dimension", err); + res.status(500).send({error: err}); + }); } _checkScalarToken(req, res) { diff --git a/src/integration/impl/StubbedFactory.js b/src/integration/impl/StubbedFactory.js new file mode 100644 index 0000000..b1476d1 --- /dev/null +++ b/src/integration/impl/StubbedFactory.js @@ -0,0 +1,11 @@ +/** + * Creates an integration using the given + * @param {DimensionStore} db the database + * @param {*} integrationConfig the integration configuration + * @param {string} roomId the room ID + * @param {string} scalarToken the scalar token + * @returns {Promise<*>} resolves to the configured integration + */ +module.exports = (db, integrationConfig, roomId, scalarToken) => { + throw new Error("Not implemented"); +}; \ No newline at end of file diff --git a/src/integration/impl/index.js b/src/integration/impl/index.js new file mode 100644 index 0000000..510bfce --- /dev/null +++ b/src/integration/impl/index.js @@ -0,0 +1,7 @@ +var RSSFactory = require("./rss/RSSFactory"); + +module.exports = { + "complex-bot": { + "rss": RSSFactory + } +}; \ No newline at end of file diff --git a/src/integration/impl/rss/RSSBot.js b/src/integration/impl/rss/RSSBot.js new file mode 100644 index 0000000..ae2461e --- /dev/null +++ b/src/integration/impl/rss/RSSBot.js @@ -0,0 +1,35 @@ +var ComplexBot = require("../../type/ComplexBot"); + +/** + * Represents an RSS bot + */ +class RSSBot extends ComplexBot { + + /** + * Creates a new RSS bot + * @param botConfig the bot configuration + * @param backbone the backbone powering this bot + */ + constructor(botConfig, backbone) { + super(botConfig); + this._backbone = backbone; + } + + /*override*/ + getUserId() { + return this._backbone.getUserId(); + } + + getFeeds() { + return this._backbone.getFeeds(); + } + + /*override*/ + getState() { + return this.getFeeds().then(feeds => { + return {feeds: feeds}; + }); + } +} + +module.exports = RSSBot; \ No newline at end of file diff --git a/src/integration/impl/rss/RSSFactory.js b/src/integration/impl/rss/RSSFactory.js new file mode 100644 index 0000000..95f808f --- /dev/null +++ b/src/integration/impl/rss/RSSFactory.js @@ -0,0 +1,12 @@ +var RSSBot = require("./RSSBot"); +var VectorRssBackbone = require("./VectorRssBackbone"); + +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 VectorRssBackbone(roomId, upstreamToken); + return new RSSBot(integrationConfig, backbone); + }); + } else throw new Error("Unsupported config"); +}; \ No newline at end of file diff --git a/src/integration/impl/rss/StubbedRssBackbone.js b/src/integration/impl/rss/StubbedRssBackbone.js new file mode 100644 index 0000000..6111272 --- /dev/null +++ b/src/integration/impl/rss/StubbedRssBackbone.js @@ -0,0 +1,29 @@ +/** + * Stubbed/placeholder RSS backbone + */ +class StubbedRssBackbone { + + /** + * Creates a new stubbed RSS backbone + */ + constructor() { + } + + /** + * Gets the user ID for this backbone + * @returns {Promise} resolves to the user ID + */ + getUserId() { + throw new Error("Not implemented"); + } + + /** + * Gets the feeds for this backbone + * @returns {Promise} resolves to the collection of feeds + */ + getFeeds() { + throw new Error("Not implemented"); + } +} + +module.exports = StubbedRssBackbone; \ No newline at end of file diff --git a/src/integration/impl/rss/VectorRssBackbone.js b/src/integration/impl/rss/VectorRssBackbone.js new file mode 100644 index 0000000..0155e75 --- /dev/null +++ b/src/integration/impl/rss/VectorRssBackbone.js @@ -0,0 +1,45 @@ +var StubbedRssBackbone = require("./StubbedRssBackbone"); +var VectorScalarClient = require("../../../scalar/VectorScalarClient"); +var _ = require("lodash"); + +/** + * Backbone for RSS bots running on vector.im through scalar + */ +class VectorRssBackbone extends StubbedRssBackbone { + + /** + * Creates a new Vector RSS backbone + * @param {string} roomId the room ID to manage + * @param {string} upstreamScalarToken the vector scalar token + */ + constructor(roomId, upstreamScalarToken) { + super(); + this._roomId = roomId; + this._scalarToken = upstreamScalarToken; + this._info = null; + } + + /*override*/ + getUserId() { + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + return this._info.bot_user_id; + }); + } + + /*override*/ + getFeeds() { + return (this._info ? Promise.resolve() : this._getInfo()).then(() => { + if (this._info.integrations.length == 0) return []; + return _.keys(this._info.integrations[0].config.feeds); + }); + } + + _getInfo() { + return VectorScalarClient.getIntegration("rssbot", this._roomId, this._scalarToken).then(info => { + this._info = info; + }); + } + +} + +module.exports = VectorRssBackbone; \ No newline at end of file diff --git a/src/integration/type/ComplexBot.js b/src/integration/type/ComplexBot.js new file mode 100644 index 0000000..fd96b46 --- /dev/null +++ b/src/integration/type/ComplexBot.js @@ -0,0 +1,18 @@ +var IntegrationStub = require("./IntegrationStub"); + +/** + * Represents a bot with additional configuration or setup needs. Normally indicates a bot needs + * more than a simple invite to the room. + */ +class ComplexBot extends IntegrationStub { + + /** + * Creates a new complex bot + * @param botConfig the configuration for the bot + */ + constructor(botConfig) { + super(botConfig); + } +} + +module.exports = ComplexBot; \ No newline at end of file diff --git a/src/integration/type/IntegrationStub.js b/src/integration/type/IntegrationStub.js new file mode 100644 index 0000000..93d30fb --- /dev/null +++ b/src/integration/type/IntegrationStub.js @@ -0,0 +1,26 @@ +/** + * Stub for an Integration + */ +class IntegrationStub { + constructor(botConfig) { + this._config = botConfig; + } + + /** + * Gets the user ID for this bot + * @return {Promise} resolves to the user ID + */ + getUserId() { + return Promise.resolve(this._config.userId); + } + + /** + * Gets state information that represents how this bot is operating. + * @return {Promise<*>} resolves to the state information + */ + getState() { + return Promise.resolve({}); + } +} + +module.exports = IntegrationStub; diff --git a/src/scalar/VectorScalarClient.js b/src/scalar/VectorScalarClient.js index 5c1d329..4c1f05d 100644 --- a/src/scalar/VectorScalarClient.js +++ b/src/scalar/VectorScalarClient.js @@ -46,6 +46,42 @@ class VectorScalarClient { }); } + /** + * Configures an Integration on Vector + * @param {string} type the integration tpye + * @param {string} scalarToken the scalar token + * @param {*} config the config to POST to the service + * @return {Promise<>} resolves when completed + */ + configureIntegration(type, scalarToken, config) { + 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); + } + + // no success processing + }); + } + + /** + * Gets information on + * @param {string} type the type to lookup + * @param {string} roomId the room ID to look in + * @param {string} scalarToken the scalar token + * @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) => { + if (response.statusCode !== 200) { + log.error("VectorScalarClient", response.body); + return Promise.reject(response.body); + } + + return response.body; + }); + } + _do(method, endpoint, qs = null, body = null) { var url = config.get("upstreams.vector") + endpoint; diff --git a/src/storage/DimensionStore.js b/src/storage/DimensionStore.js index dac969d..4221902 100644 --- a/src/storage/DimensionStore.js +++ b/src/storage/DimensionStore.js @@ -86,7 +86,7 @@ class DimensionStore { */ checkToken(scalarToken) { return this.__Tokens.find({where: {scalarToken: scalarToken}}).then(token => { - if (!token) return Promise.reject(); + if (!token) return Promise.reject(new Error("Token not found")); //if (moment().isAfter(moment(token.expires))) return this.__Tokens.destroy({where: {id: token.id}}).then(() => Promise.reject()); return Promise.resolve(); }); diff --git a/web/app/riot/riot.component.ts b/web/app/riot/riot.component.ts index 531c458..693ae01 100644 --- a/web/app/riot/riot.component.ts +++ b/web/app/riot/riot.component.ts @@ -40,7 +40,7 @@ export class RiotComponent { } private init() { - this.api.getIntegrations().then(integrations => { + this.api.getIntegrations(this.roomId, this.scalarToken).then(integrations => { this.integrations = integrations; let promises = integrations.map(b => this.updateIntegrationState(b)); return Promise.all(promises); diff --git a/web/app/shared/api.service.ts b/web/app/shared/api.service.ts index 9d82be4..b85af90 100644 --- a/web/app/shared/api.service.ts +++ b/web/app/shared/api.service.ts @@ -7,18 +7,22 @@ export class ApiService { constructor(private http: Http) { } - checkScalarToken(token): Promise { - return this.http.get("/api/v1/scalar/checkToken", {params: {scalar_token: token}}) + checkScalarToken(scalarToken): Promise { + return this.http.get("/api/v1/scalar/checkToken", {params: {scalar_token: scalarToken}}) .map(res => res.status === 200).toPromise(); } - getIntegrations(): Promise { - return this.http.get("/api/v1/dimension/integrations") + getIntegrations(roomId, scalarToken): Promise { + return this.http.get("/api/v1/dimension/integrations/" + roomId, {params: {scalar_token: scalarToken}}) .map(res => res.json()).toPromise(); } removeIntegration(roomId: string, userId: string, scalarToken: string): Promise { - return this.http.post("/api/v1/dimension/removeIntegration", {roomId: roomId, userId: userId, scalarToken: scalarToken}) + return this.http.post("/api/v1/dimension/removeIntegration", { + roomId: roomId, + userId: userId, + scalarToken: scalarToken + }) .map(res => res.json()).toPromise(); } } diff --git a/web/public/img/avatars/rssbot.png b/web/public/img/avatars/rssbot.png new file mode 100644 index 0000000..f7dc7ca Binary files /dev/null and b/web/public/img/avatars/rssbot.png differ diff --git a/web/public/img/avatars/travisci.png b/web/public/img/avatars/travisci.png new file mode 100644 index 0000000..b8d4700 Binary files /dev/null and b/web/public/img/avatars/travisci.png differ