diff --git a/docs/integrations/irc_bridge.md b/docs/integrations/irc_bridge.md index 36fa71c..9ea9a0e 100644 --- a/docs/integrations/irc_bridge.md +++ b/docs/integrations/irc_bridge.md @@ -27,7 +27,7 @@ Make a call to the Dimension state API: `GET /api/v1/dimension/integrations/{roo ## Getting the OPs in a channel -IRC API Endpoint: `GET /api/v1/irc/{network}/{channel}/ops?scalar_token=...`. Be sure to encode the channel parameter. +IRC API Endpoint: `GET /api/v1/irc/{roomId}/ops/{network}/{channel}?scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`). *Example response* ``` @@ -36,12 +36,12 @@ IRC API Endpoint: `GET /api/v1/irc/{network}/{channel}/ops?scalar_token=...`. Be ## Linking a new channel -IRC API Endpoint: `PUT /api/v1/irc/{roomId}/channels/{network}/{channel}?op=turt2live&scalar_token=...`. Be sure to encode the channel parameter. +IRC API Endpoint: `PUT /api/v1/irc/{roomId}/channels/{network}/{channel}?op=turt2live&scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`). A 200 OK is returned if the request to add the channel was sent. The channel will not appear in the state information until the op has approved the bridge. ## Unlinking a channel -IRC API Endpoint: `DELETE /api/v1/irc/{roomId}/channels/{network}/{channel}?scalar_token=...`. Be sure to encode the channel parameter. +IRC API Endpoint: `DELETE /api/v1/irc/{roomId}/channels/{network}/{channel}?scalar_token=...`. The channel should not include the prefix (`#test` becomes `test`). A 200 OK is returned if the delete was successful. \ No newline at end of file diff --git a/src/Dimension.js b/src/Dimension.js index ae45adf..e8c9bf1 100644 --- a/src/Dimension.js +++ b/src/Dimension.js @@ -6,6 +6,7 @@ var bodyParser = require('body-parser'); var path = require("path"); var DimensionApi = require("./DimensionApi"); var ScalarApi = require("./ScalarApi"); +var IRCApi = require("./integration/impl/irc/IRCApi"); // TODO: Convert backend to typescript? Would avoid stubbing classes all over the place @@ -50,6 +51,7 @@ class Dimension { DimensionApi.bootstrap(this._app, this._db); ScalarApi.bootstrap(this._app, this._db); + IRCApi.bootstrap(this._app, this._db); this._app.listen(config.get('web.port'), config.get('web.address')); log.info("Dimension", "API and UI listening on " + config.get("web.address") + ":" + config.get("web.port")); diff --git a/src/integration/generic_types/Bridge.js b/src/integration/generic_types/Bridge.js index 85e4cd5..bb1a463 100644 --- a/src/integration/generic_types/Bridge.js +++ b/src/integration/generic_types/Bridge.js @@ -13,14 +13,6 @@ class Bridge extends IntegrationStub { super(bridgeConfig); } - /** - * Registers the API routes for this bridge with the given app. - * @param app the app to register the routes on - */ - registerApi(app) { - // nothing - } - /*override*/ getUserId() { return null; // bridges don't have bot users we care about diff --git a/src/integration/impl/irc/IRCApi.js b/src/integration/impl/irc/IRCApi.js new file mode 100644 index 0000000..3da6385 --- /dev/null +++ b/src/integration/impl/irc/IRCApi.js @@ -0,0 +1,109 @@ +var Integrations = require("../../index"); +var IntegrationImpl = require("../index"); +var log = require("../../../util/LogService"); + +/** + * API Handler for the IRC integration + */ +class IRCApi { + + /** + * Creates a new IRC API + */ + constructor() { + } + + bootstrap(app, db) { + if (!Integrations.byType["bridge"]["irc"]) { + log.info("IRCApi", "IRC Bridge not enabled - not setting up the API"); + return; + } else log.info("IRCApi", "Setting up IRC API"); + + this._db = db; + + app.get("/api/v1/irc/:roomId/ops/:network/:channel", this._getChannelOps.bind(this)); + app.put("/api/v1/irc/:roomId/channels/:network/:channel", this._addChannel.bind(this)); + app.delete("/api/v1/irc/:roomId/channels/:network/:channel", this._deleteChannel.bind(this)); + } + + _getChannelOps(req, res) { + this._generalProcessing(req, res).then(ircBridge => { + var network = req.params.network; + var channel = req.params.channel; + return ircBridge.getChannelOps(network, channel).catch(err => { + log.error("IRCApi", err); + console.error(err); + res.status(500).send({error: err}); + return null; + }); + }).then(ops => { + if (ops !== null) res.status(200).send(ops); + }).catch(() => null); + } + + _addChannel(req, res) { + this._generalProcessing(req, res).then(ircBridge => { + var network = req.params.network; + var channel = req.params.channel; + var op = req.query.op; + return ircBridge.addChannel(network, channel, op).catch(err => { + log.error("IRCApi", err); + console.error(err); + res.status(500).send({error: err}); + return null; + }); + }).then(result => { + if (result !== null) res.status(200).send({successful: true}); + }).catch(() => null); + } + + _deleteChannel(req, res) { + this._generalProcessing(req, res).then(ircBridge => { + var network = req.params.network; + var channel = req.params.channel; + return ircBridge.removeChannel(network, channel).catch(err => { + log.error("IRCApi", err); + console.error(err); + res.status(500).send({error: err}); + return null; + }); + }).then(result => { + if (result !== null) res.status(200).send({successful: true}); + }).catch(() => null); + } + + _generalProcessing(req, res) { + return new Promise((resolve, reject) => { + res.setHeader("Content-Type", "application/json"); + + var roomId = req.params.roomId; + var network = req.params.network; + var channel = req.params.channel; + if (!roomId || !network || !channel) { + res.status(400).send({error: 'Missing room ID, network, or channel'}); + reject(); + return; + } + + var scalarToken = req.query.scalar_token; + this._db.checkToken(scalarToken).then(() => { + var conf = Integrations.byType["bridge"]["irc"]; + var factory = IntegrationImpl.getFactory(conf); + factory(this._db, conf, roomId, scalarToken).then(resolve).catch(err => { + log.error("IRCApi", err); + console.error(err); + res.status(500).send({error: err}); + reject(); + }); + }).catch(err => { + log.error("IRCApi", err); + console.error(err); + res.status(500).send({error: err}); + reject(); + }); + }); + } + +} + +module.exports = new IRCApi(); \ No newline at end of file diff --git a/src/integration/impl/irc/IRCBridge.js b/src/integration/impl/irc/IRCBridge.js index 927f3cb..95a20bc 100644 --- a/src/integration/impl/irc/IRCBridge.js +++ b/src/integration/impl/irc/IRCBridge.js @@ -39,6 +39,37 @@ class IRCBridge extends Bridge { updateState(newState) { throw new Error("State cannot be updated for an IRC bridge. Use the IRC API instead."); } + + /** + * Gets a list of operators available in a particular channel on a particular network + * @param {string} network the network to look at + * @param {string} channel the channel to look in (without prefixed #) + * @returns {Promise} resolves to a list of operators + */ + getChannelOps(network, channel) { + return this._backbone.getChannelOps(network, channel); + } + + /** + * Links a channel to the room this bridge controls + * @param {string} network the network to link to + * @param {string} channel the channel to link to + * @param {string} op the channel operator to request permission from + * @returns {Promise<>} resolves when complete + */ + addChannel(network, channel, op) { + return this._backbone.addChannel(network, channel, op); + } + + /** + * Unlinks a channel from the room this bridge controls + * @param {string} network the network to unlink from + * @param {string} channel the channel to unlink + * @returns {Promise<>} resolves when complete + */ + removeChannel(network, channel) { + return this._backbone.removeChannel(network, channel); + } } module.exports = IRCBridge; \ No newline at end of file diff --git a/src/integration/impl/irc/StubbedIrcBackbone.js b/src/integration/impl/irc/StubbedIrcBackbone.js index fea0e56..2b323d1 100644 --- a/src/integration/impl/irc/StubbedIrcBackbone.js +++ b/src/integration/impl/irc/StubbedIrcBackbone.js @@ -24,6 +24,37 @@ class StubbedIrcBackbone { getLinkedChannels() { return Promise.resolve({}); } + + /** + * Gets a list of operators available in a particular channel on a particular network + * @param {string} network the network to look at + * @param {string} channel the channel to look in (without prefixed #) + * @returns {Promise} resolves to a list of operators + */ + getChannelOps(network, channel) { + return Promise.resolve([]); + } + + /** + * Links a channel to the room this backbone controls + * @param {string} network the network to link to + * @param {string} channel the channel to link to + * @param {string} op the channel operator to request permission from + * @returns {Promise<>} resolves when complete + */ + addChannel(network, channel, op) { + throw new Error("Not implemented"); + } + + /** + * Unlinks a channel from the room this backbone controls + * @param {string} network the network to unlink from + * @param {string} channel the channel to unlink + * @returns {Promise<>} resolves when complete + */ + removeChannel(network, channel) { + throw new Error("Not implemented"); + } } module.exports = StubbedIrcBackbone; \ No newline at end of file diff --git a/src/integration/impl/irc/VectorIrcBackbone.js b/src/integration/impl/irc/VectorIrcBackbone.js index 9225c13..65165ae 100644 --- a/src/integration/impl/irc/VectorIrcBackbone.js +++ b/src/integration/impl/irc/VectorIrcBackbone.js @@ -50,13 +50,64 @@ class VectorIrcBackbone extends StubbedIrcBackbone { throw new Error("Unexpected RID"); } - container[server.id].push(link.channel); + container[server].push(link.channel); } return container; }); } + /*override*/ + getChannelOps(network, channel) { + return this._getNetworks().then(networks => { + var networkServer = null; + var rid = null; + for (var n of networks) { + if (n.id === network) { + networkServer = n.domain; + rid = n.rid; + break; + } + } + + return VectorScalarClient.getIrcOperators(rid, networkServer, '#' + channel, this._scalarToken); + }); + } + + /*override*/ + addChannel(network, channel, op) { + return this._getNetworks().then(networks => { + var networkServer = null; + var rid = null; + for (var n of networks) { + if (n.id === network) { + networkServer = n.domain; + rid = n.rid; + break; + } + } + + return VectorScalarClient.addIrcLink(rid, this._roomId, networkServer, '#' + channel, op, this._scalarToken); + }); + } + + /*override*/ + removeChannel(network, channel) { + return this._getNetworks().then(networks => { + var networkServer = null; + var rid = null; + for (var n of networks) { + if (n.id === network) { + networkServer = n.domain; + rid = n.rid; + break; + } + } + + return VectorScalarClient.removeIrcLink(rid, this._roomId, networkServer, '#' + channel, this._scalarToken); + }); + } + _getNetworks() { if (this._lastNetworkResponse !== null) return Promise.resolve(this._lastNetworkResponse); return VectorScalarClient.getIrcNetworks(this._scalarToken).then(networks => { diff --git a/src/scalar/VectorScalarClient.js b/src/scalar/VectorScalarClient.js index ec685f7..0b932ce 100644 --- a/src/scalar/VectorScalarClient.js +++ b/src/scalar/VectorScalarClient.js @@ -159,6 +159,80 @@ class VectorScalarClient { }); } + /** + * Gets a list of operators in a particular channel on a particular network + * @param {string} rid the network ID + * @param {string} networkServer the server that has the channel on it + * @param {string} channel the channel to look up, with prefix + * @param {string} scalarToken the scalar token + * @returns {Promise} resolves to a list of operators in the channel + */ + getIrcOperators(rid, networkServer, channel, scalarToken) { + return this._do("POST", "/bridges/irc/_matrix/provision/querylink", {scalar_token: scalarToken, rid: rid}, { + remote_room_server: networkServer, + remote_room_channel: channel + }).then((response, body) => { + if (response.statusCode !== 200) { + log.error("VectorScalarClient", response.body); + return Promise.reject(response.body); + } + + if (response.body["replies"]) { + return response.body["replies"][0]["response"]["operators"]; + } else return Promise.reject("No operators could be found"); + }); + } + + /** + * Requests an operator for permission to link an IRC channel to a matrix room + * @param {string} rid the network ID + * @param {string} roomId the matrix room ID + * @param {string} networkServer the server that has the channel on it + * @param {string} channel the channel to look up, with prefix + * @param {string} operator the channel operator's nick + * @param {string} scalarToken the scalar token + * @returns {Promise<>} resolves when completed + */ + addIrcLink(rid, roomId, networkServer, channel, operator, scalarToken) { + return this._do("POST", "/bridges/irc/_matrix/provision/link", {rid: rid, scalar_token: scalarToken}, { + matrix_room_id: roomId, + remote_room_channel: channel, + remote_room_server: networkServer, + op_nick: operator + }).then((response, body) => { + if (response.statusCode !== 200) { + log.error("VectorScalarClient", response.body); + return Promise.reject(response.body); + } + + return {status: 'ok'}; + }) + } + + /** + * Removes a channel link from a Matrix room + * @param {string} rid the network ID + * @param {string} roomId the matrix room ID + * @param {string} networkServer the server that has the channel on it + * @param {string} channel the channel to remove, with prefix + * @param {string} scalarToken the scalar token + * @returns {Promise<>} resolves when completed + */ + removeIrcLink(rid, roomId, networkServer, channel, scalarToken) { + return this._do("POST", "/bridges/irc/_matrix/provision/unlink", {rid: rid, scalar_token: scalarToken}, { + matrix_room_id: roomId, + remote_room_channel: channel, + remote_room_server: networkServer + }).then((response, body) => { + if (response.statusCode !== 200) { + log.error("VectorScalarClient", response.body); + return Promise.reject(response.body); + } + + return {status: 'ok'}; + }) + } + _do(method, endpoint, qs = null, body = null) { var url = config.get("upstreams.vector") + endpoint; diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 8853005..5828710 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -20,6 +20,7 @@ import { BootstrapModalModule } from "angular2-modal/plugins/bootstrap"; import { ModalModule } from "angular2-modal"; import { RssConfigComponent } from "./configs/rss/rss-config.component"; import { IrcConfigComponent } from "./configs/irc/irc-config.component"; +import { IrcApiService } from "./shared/irc-api.service"; @NgModule({ imports: [ @@ -49,6 +50,7 @@ import { IrcConfigComponent } from "./configs/irc/irc-config.component"; ApiService, ScalarService, IntegrationService, + IrcApiService, // Vendor ], diff --git a/web/app/configs/irc/irc-config.component.html b/web/app/configs/irc/irc-config.component.html index f458943..b14f4d7 100644 --- a/web/app/configs/irc/irc-config.component.html +++ b/web/app/configs/irc/irc-config.component.html @@ -5,6 +5,71 @@

Configure IRC Bridge

-
{{ integration | json }}
+
+ Bridging a channel requires authorization from a channel operator. When entering a channel below, a bot will + join the channel to ensure it exists and has operators available. +
+
+
Add a channel
+
+ +
+ +
+
+
+ +
+
#
+ +
+
+
+
+

{{ opsError }}

+ +
+
+
+
+
Add a channel
+
+ +
+ +
+
+
+
+

A request to bridge #{{ newChannel.channel }} on {{ getNewChannelNetworkName() }} will be sent to {{ newChannel.op }}. Once they accept, the channel will show up below.

+ +
+
+
+
+
+
Linked channels
+

No linked channels.

+
+
+ {{ link.displayName }} +
+ {{ channel }} + +
+
+
\ No newline at end of file diff --git a/web/app/configs/irc/irc-config.component.ts b/web/app/configs/irc/irc-config.component.ts index 820e544..d6f0e9f 100644 --- a/web/app/configs/irc/irc-config.component.ts +++ b/web/app/configs/irc/irc-config.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { IRCIntegration } from "../../shared/models/integration"; import { ModalComponent, DialogRef } from "angular2-modal"; import { ConfigModalContext } from "../../integration/integration.component"; +import { IrcApiService } from "../../shared/irc-api.service"; +import { ToasterService } from "angular2-toaster"; @Component({ selector: 'my-irc-config', @@ -11,15 +13,139 @@ import { ConfigModalContext } from "../../integration/integration.component"; export class IrcConfigComponent implements ModalComponent { public integration: IRCIntegration; + public loadingOps = false; + public opsLoaded = false; + public addingChannel = false; + public newChannel = { + network: "", + channel: "", + op: "" + }; + public channelOps: string[]; + public opsError: string = null; + public channelLinks: ChannelLink[]; private roomId: string; private scalarToken: string; - constructor(public dialog: DialogRef) {// , - // private toaster: ToasterService, - // private api: ApiService) { + constructor(public dialog: DialogRef, + private ircApi: IrcApiService, + private toaster: ToasterService, + // private api: ApiService + ) { this.integration = dialog.context.integration; this.roomId = dialog.context.roomId; this.scalarToken = dialog.context.scalarToken; + + this.newChannel.network = this.integration.availableNetworks[0].id; + this.buildChannelLinks(); + } + + public checkOps(): void { + if (this.newChannel.channel.trim().length === 0) { + this.toaster.pop("warning", "Please enter a channel name"); + return; + } + + this.loadingOps = true; + this.ircApi.getChannelOps(this.roomId, this.newChannel.network, this.newChannel.channel, this.scalarToken).then(ops => { + this.channelOps = ops; + if (this.channelOps.length === 0) { + this.opsError = "No channel operators available"; + } else { + this.newChannel.op = this.channelOps[0]; + this.loadingOps = false; + this.opsLoaded = true; + } + }).catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.loadingOps = false; + }); + } + + public getNewChannelNetworkName(): string { + for (let network of this.integration.availableNetworks) { + if (network.id === this.newChannel.network) { + return network.name; + } + } + return "Unknown"; + } + + public addChannel(): void { + this.addingChannel = true; + this.ircApi.linkChannel( + this.roomId, + this.newChannel.network, + this.newChannel.channel, + this.newChannel.op, + this.scalarToken) + .then(() => { + this.newChannel = { + network: this.integration.availableNetworks[0].id, + channel: "", + op: "" + }; + this.channelOps = []; + this.addingChannel = false; + this.opsLoaded = false; + + this.toaster.pop("success", "Channel bridge requested"); + }) + .catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + this.addingChannel = false; + }); + } + + private buildChannelLinks(): void { + this.channelLinks = []; + + for (let network in this.integration.channels) { + if (this.integration.channels[network].length <= 0) continue; + + let displayName = "Unknown Network"; + for (let parentNetwork of this.integration.availableNetworks) { + if (parentNetwork.id === network) { + displayName = parentNetwork.name; + break; + } + } + + this.channelLinks.push({ + displayName: displayName, + network: network, + channels: this.integration.channels[network], + beingRemoved: [] + }); + } + } + + public removeChannelLink(link: ChannelLink, channel: string): void { + link.beingRemoved.push(channel); + this.ircApi.unlinkChannel(this.roomId, link.network, channel.substring(1), this.scalarToken).then(() => { + this.toaster.pop("success", "Channel " + channel + " unlinked"); + + link.channels.splice(link.channels.indexOf(channel), 1); + link.beingRemoved.splice(link.beingRemoved.indexOf(channel), 1); + + if (link.channels.length === 0) { + this.channelLinks.splice(this.channelLinks.indexOf(link), 1); + } + }).catch(err => { + this.toaster.pop("error", err.json().error); + console.error(err); + + link.beingRemoved.splice(link.beingRemoved.indexOf(channel), 1); + }); } } + +interface ChannelLink { + displayName: string; + channels: string[]; + network: string; + beingRemoved: string[]; +} diff --git a/web/app/shared/api.service.ts b/web/app/shared/api.service.ts index 52f8dc4..748e68f 100644 --- a/web/app/shared/api.service.ts +++ b/web/app/shared/api.service.ts @@ -7,12 +7,12 @@ export class ApiService { constructor(private http: Http) { } - checkScalarToken(scalarToken): Promise { + checkScalarToken(scalarToken: string): Promise { return this.http.get("/api/v1/scalar/checkToken", {params: {scalar_token: scalarToken}}) .map(res => res.status === 200).toPromise(); } - getIntegrations(roomId, scalarToken): Promise { + getIntegrations(roomId: string, scalarToken: string): Promise { return this.http.get("/api/v1/dimension/integrations/" + roomId, {params: {scalar_token: scalarToken}}) .map(res => res.json()).toPromise(); } diff --git a/web/app/shared/irc-api.service.ts b/web/app/shared/irc-api.service.ts new file mode 100644 index 0000000..0d633af --- /dev/null +++ b/web/app/shared/irc-api.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; + +@Injectable() +export class IrcApiService { + constructor(private http: Http) { + } + + getChannelOps(roomId: string, networkId: string, channel: string, scalarToken: string): Promise { + return this.http.get("/api/v1/irc/" + roomId + "/ops/" + networkId + "/" + channel, {params: {scalar_token: scalarToken}}) + .map(res => res.json()).toPromise(); + } + + linkChannel(roomId: string, networkId: string, channel: string, op: string, scalarToken: string): Promise { + return this.http.put("/api/v1/irc/" + roomId + "/channels/" + networkId + "/" + channel, {}, { + params: { + scalar_token: scalarToken, + op: op + } + }).map(res => res.json()).toPromise(); + } + + unlinkChannel(roomId: string, networkId: string, channel: string, scalarToken: string): Promise { + return this.http.delete("/api/v1/irc/" + roomId + "/channels/" + networkId + "/" + channel, {params: {scalar_token: scalarToken}}) + .map(res => res.json()).toPromise(); + } +} diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index b2fbb32..a5c91eb 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -18,6 +18,6 @@ export interface RSSIntegration extends Integration { } export interface IRCIntegration extends Integration { - availableNetworks: {name: string, id: string}; + availableNetworks: {name: string, id: string}[]; channels: {[networkId: string]: string[]}; } \ No newline at end of file