Self-service requests to bridge IRC channels

This commit is contained in:
Travis Ralston 2018-03-31 14:37:36 -06:00
parent 1e437a2f8b
commit f33f7e5716
16 changed files with 450 additions and 43 deletions

View File

@ -46,14 +46,7 @@ export class DimensionIntegrationsService {
* @returns {Promise<Bridge[]>} Resolves to the bridge list
*/
public static async getBridges(enabledOnly: boolean, forUserId: string, inRoomId?: string): Promise<Bridge[]> {
const cacheKey = inRoomId ? "bridges_" + inRoomId : "bridges";
const cached = Cache.for(CACHE_INTEGRATIONS).get(cacheKey);
if (cached) return cached;
const bridges = await BridgeStore.listAll(forUserId, enabledOnly ? true : null, inRoomId);
Cache.for(CACHE_INTEGRATIONS).put(cacheKey, bridges);
return bridges;
return BridgeStore.listAll(forUserId, enabledOnly ? true : null, inRoomId);
}
/**

View File

@ -0,0 +1,49 @@
import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest";
import { LogService } from "matrix-js-snippets";
import { ScalarService } from "../scalar/ScalarService";
import { IrcBridge } from "../../bridges/IrcBridge";
import IrcBridgeRecord from "../../db/models/IrcBridgeRecord";
import { ApiError } from "../ApiError";
interface RequestLinkRequest {
op: string;
}
/**
* API for interacting with the IRC bridge
*/
@Path("/api/v1/dimension/irc")
export class DimensionIrcService {
@GET
@Path(":networkId/channel/:channel/ops")
public async getOps(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string): Promise<string[]> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const parsed = IrcBridge.parseNetworkId(networkId);
const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
const client = new IrcBridge(userId);
const operators = await client.getOperators(bridge, parsed.bridgeNetworkId, "#" + channelNoHash);
LogService.info("DimensionIrcService", userId + " listed the operators for #" + channelNoHash + " on " + networkId);
return operators;
}
@POST
@Path(":networkId/channel/:channel/link/:roomId")
public async requestLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string, @PathParam("roomId") roomId: string, request: RequestLinkRequest): Promise<any> {
const userId = await ScalarService.getTokenOwner(scalarToken);
const parsed = IrcBridge.parseNetworkId(networkId);
const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId);
if (!bridge) throw new ApiError(404, "Bridge not found");
const client = new IrcBridge(userId);
await client.requestLink(bridge, parsed.bridgeNetworkId, "#" + channelNoHash, request.op, roomId);
LogService.info("DimensionIrcService", userId + " requested #" + channelNoHash + " on " + networkId + " to be linked to " + roomId);
return {}; // 200 OK
}
}

View File

@ -5,9 +5,9 @@ import Upstream from "../db/models/Upstream";
import UserScalarToken from "../db/models/UserScalarToken";
import { LogService } from "matrix-js-snippets";
import * as request from "request";
import { QueryNetworksResponse } from "./models/provision_responses";
import { ModularIrcQueryNetworksResponse } from "../models/ModularResponses";
import { ListLinksResponseItem, ListOpsResponse, QueryNetworksResponse } from "./models/irc";
import IrcBridgeNetwork from "../db/models/IrcBridgeNetwork";
import { ModularIrcResponse } from "../models/ModularResponses";
interface CachedNetwork {
ircBridgeId: number;
@ -27,6 +27,12 @@ export interface AvailableNetworks {
};
}
export interface LinkedChannels {
[networkId: string]: {
channelName: string;
}[];
}
export class IrcBridge {
private static getNetworkId(network: CachedNetwork): string {
return network.ircBridgeId + "-" + network.bridgeNetworkId;
@ -47,9 +53,10 @@ export class IrcBridge {
return allNetworks.length > 0;
}
public async getNetworks(bridge?: IrcBridgeRecord): Promise<AvailableNetworks> {
public async getNetworks(bridge?: IrcBridgeRecord, enabledOnly?: boolean): Promise<AvailableNetworks> {
let networks = await this.getAllNetworks();
if (bridge) networks = networks.filter(n => n.ircBridgeId === bridge.id);
if (enabledOnly) networks = networks.filter(n => n.isEnabled);
const available: AvailableNetworks = {};
networks.forEach(n => available[IrcBridge.getNetworkId(n)] = {
@ -61,12 +68,63 @@ export class IrcBridge {
return available;
}
public async getRoomConfiguration(requestingUserId: string, inRoomId: string): Promise<IrcBridgeConfiguration> {
return <any>{requestingUserId, inRoomId};
public async getRoomConfiguration(inRoomId: string): Promise<IrcBridgeConfiguration> {
const availableNetworks = await this.getNetworks(null, true);
const bridges = await IrcBridgeRecord.findAll({where: {isEnabled: true}});
const linkedChannels: LinkedChannels = {};
for (const bridge of bridges) {
const links = await this.fetchLinks(bridge, inRoomId);
for (const key of Object.keys(links)) {
linkedChannels[key] = links[key];
}
}
return {availableNetworks: availableNetworks, links: linkedChannels};
}
public async setRoomConfiguration(requestingUserId: string, inRoomId: string, newConfig: IrcBridgeConfiguration): Promise<any> {
return <any>{requestingUserId, inRoomId, newConfig};
public async getOperators(bridge: IrcBridgeRecord, networkId: string, channel: string): Promise<string[]> {
const network = (await this.getAllNetworks()).find(n => n.isEnabled && n.ircBridgeId === bridge.id && n.bridgeNetworkId === networkId);
if (!network) throw new Error("Network not found");
const requestBody = {remote_room_server: network.domain, remote_room_channel: channel};
let responses: ListOpsResponse[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcResponse<ListOpsResponse>>(bridge, "POST", "/bridges/irc/_matrix/provision/querylink", null, requestBody);
if (result && result.replies) responses = result.replies.map(r => r.response);
} else {
const result = await this.doProvisionRequest<ListOpsResponse>(bridge, "POST", "/_matrix/provision/querylink", null, requestBody);
if (result) responses = [result];
}
const ops: string[] = [];
for (const response of responses) {
if (!response || !response.operators) continue;
response.operators.forEach(i => ops.push(i));
}
return ops;
}
public async requestLink(bridge: IrcBridgeRecord, networkId: string, channel: string, op: string, inRoomId: string): Promise<any> {
const network = (await this.getAllNetworks()).find(n => n.isEnabled && n.ircBridgeId === bridge.id && n.bridgeNetworkId === networkId);
if (!network) throw new Error("Network not found");
const requestBody = {
remote_room_server: network.domain,
remote_room_channel: channel,
matrix_room_id: inRoomId,
op_nick: op,
user_id: this.requestingUserId,
};
if (bridge.upstreamId) {
delete requestBody["user_id"];
await this.doUpstreamRequest(bridge, "POST", "/bridges/irc/_matrix/provision/link", null, requestBody);
} else {
await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/link", null, requestBody);
}
}
private async getAllNetworks(): Promise<CachedNetwork[]> {
@ -86,10 +144,50 @@ export class IrcBridge {
return networks;
}
private async fetchLinks(bridge: IrcBridgeRecord, inRoomId: string): Promise<LinkedChannels> {
const availableNetworks = await this.getNetworks(bridge, true);
const networksByDomain: { [domain: string]: { id: string, name: string, bridgeUserId: string } } = {};
for (const key of Object.keys(availableNetworks)) {
const network = availableNetworks[key];
networksByDomain[network.domain] = {
id: key,
name: network.name,
bridgeUserId: network.bridgeUserId,
};
}
let responses: ListLinksResponseItem[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcResponse<ListLinksResponseItem[]>>(bridge, "GET", "/bridges/irc/_matrix/provision/listlinks/" + inRoomId);
if (result && result.replies) {
const replies = result.replies.map(r => r.response);
for (const reply of replies) reply.forEach(r => responses.push(r));
}
} else {
const result = await this.doProvisionRequest<ListLinksResponseItem[]>(bridge, "GET", "/_matrix/provision/listlinks/" + inRoomId);
if (result) responses = result;
}
const linked: LinkedChannels = {};
for (const response of responses) {
if (!response || !response.remote_room_server) continue;
const network = networksByDomain[response.remote_room_server];
if (!network) continue;
if (!linked[network.id]) linked[network.id] = [];
linked[network.id].push({
channelName: response.remote_room_channel,
});
}
return linked;
}
private async fetchNetworks(bridge: IrcBridgeRecord): Promise<CachedNetwork[]> {
let responses: QueryNetworksResponse[] = [];
if (bridge.upstreamId) {
const result = await this.doUpstreamRequest<ModularIrcQueryNetworksResponse>(bridge, "GET", "/bridges/irc/_matrix/provision/querynetworks");
const result = await this.doUpstreamRequest<ModularIrcResponse<QueryNetworksResponse>>(bridge, "GET", "/bridges/irc/_matrix/provision/querynetworks");
if (result && result.replies) responses = result.replies.map(r => r.response);
} else {
const result = await this.doProvisionRequest<QueryNetworksResponse>(bridge, "GET", "/_matrix/provision/querynetworks");

View File

@ -7,4 +7,14 @@ export interface QueryNetworksResponse {
domain: string;
};
}[];
}
export interface ListLinksResponseItem {
matrix_room_id: string;
remote_room_channel: string;
remote_room_server: string;
}
export interface ListOpsResponse {
operators: string[];
}

View File

@ -32,13 +32,12 @@ export class BridgeStore {
return bridge.save();
}
public static async setBridgeRoomConfig(requestingUserId: string, integrationType: string, inRoomId: string, newConfig: any): Promise<any> {
public static async setBridgeRoomConfig(_requestingUserId: string, integrationType: string, _inRoomId: string, _newConfig: any): Promise<any> {
const record = await BridgeRecord.findOne({where: {type: integrationType}});
if (!record) throw new Error("Bridge not found");
if (integrationType === "irc") {
const irc = new IrcBridge(requestingUserId);
return irc.setRoomConfiguration(requestingUserId, inRoomId, newConfig);
throw new Error("IRC Bridges should be modified with the dedicated API");
} else throw new Error("Unsupported bridge");
}
@ -53,7 +52,7 @@ export class BridgeStore {
if (record.type === "irc") {
if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs
const irc = new IrcBridge(requestingUserId);
return irc.getRoomConfiguration(requestingUserId, inRoomId);
return irc.getRoomConfiguration(inRoomId);
} else return {};
}

View File

@ -1,6 +1,6 @@
import { Integration } from "./Integration";
import BridgeRecord from "../db/models/BridgeRecord";
import { AvailableNetworks } from "../bridges/IrcBridge";
import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge";
export class Bridge extends Integration {
constructor(bridge: BridgeRecord, public config: any) {
@ -12,17 +12,12 @@ export class Bridge extends Integration {
argument: null, // not used
}];
// We'll just say we aren't
// We'll just say we don't support encryption
this.isEncryptionSupported = false;
}
}
export interface IrcBridgeConfiguration {
availableNetworks: AvailableNetworks;
links: {
[networkId: string]: {
channelName: string;
addedByUserId: string;
}[];
};
links: LinkedChannels;
}

View File

@ -1,13 +1,11 @@
import { QueryNetworksResponse } from "../bridges/models/provision_responses";
export interface ModularIntegrationInfoResponse {
bot_user_id: string;
integrations?: any[];
}
export interface ModularIrcQueryNetworksResponse {
export interface ModularIrcResponse<T> {
replies: {
rid: string;
response: QueryNetworksResponse;
response: T;
}[];
}

View File

@ -68,6 +68,8 @@ import { AdminIrcBridgeComponent } from "./admin/bridges/irc/irc.component";
import { AdminIrcApiService } from "./shared/services/admin/admin-irc-api.service";
import { AdminIrcBridgeNetworksComponent } from "./admin/bridges/irc/networks/networks.component";
import { AdminIrcBridgeAddSelfhostedComponent } from "./admin/bridges/irc/add-selfhosted/add-selfhosted.component";
import { IrcBridgeConfigComponent } from "./configs/bridge/irc/irc.bridge.component";
import { IrcApiService } from "./shared/services/integrations/irc-api.service";
@NgModule({
imports: [
@ -129,6 +131,7 @@ import { AdminIrcBridgeAddSelfhostedComponent } from "./admin/bridges/irc/add-se
AdminIrcBridgeComponent,
AdminIrcBridgeNetworksComponent,
AdminIrcBridgeAddSelfhostedComponent,
IrcBridgeConfigComponent,
// Vendor
],
@ -144,6 +147,7 @@ import { AdminIrcBridgeAddSelfhostedComponent } from "./admin/bridges/irc/add-se
AdminNebApiService,
AdminUpstreamApiService,
AdminIrcApiService,
IrcApiService,
{provide: Window, useValue: window},
// Vendor

View File

@ -23,6 +23,7 @@ import { RssComplexBotConfigComponent } from "./configs/complex-bot/rss/rss.comp
import { TravisCiComplexBotConfigComponent } from "./configs/complex-bot/travisci/travisci.complex-bot.component";
import { AdminBridgesComponent } from "./admin/bridges/bridges.component";
import { AdminIrcBridgeComponent } from "./admin/bridges/irc/irc.component";
import { IrcBridgeConfigComponent } from "./configs/bridge/irc/irc.bridge.component";
const routes: Routes = [
{path: "", component: HomeComponent},
@ -142,15 +143,16 @@ const routes: Routes = [
},
],
},
// {
// path: "bridge",
// children: [
// {
// path: "irc",
//
// }
// ]
// }
{
path: "bridge",
children: [
{
path: "irc",
component: IrcBridgeConfigComponent,
data: {breadcrumb: "IRC Bridge Configuration", name: "IRC Bridge Configuration"},
},
],
},
],
},
{

View File

@ -0,0 +1,89 @@
<my-bridge-config [bridgeComponent]="this">
<ng-template #bridgeParamsTemplate>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
Add an IRC channel
</h5>
<div class="my-ibox-content">
<div class="alert alert-info">
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.
</div>
<div *ngIf="channelStep === 1">
<label class="label-block">
Network
<select class="form-control form-control-sm" [(ngModel)]="networkId" [disabled]="loadingOps">
<option *ngFor="let network of getNetworks()" [ngValue]="network.id">
{{ network.name }}
</option>
</select>
</label>
<label class="label-block">
Channel Name
</label>
<div class="input-group input-group-sm">
<div class="input-group-addon">#</div>
<input title="channel" type="text" class="form-control form-control-sm" [(ngModel)]="channel" [disabled]="loadingOps">
</div>
<div style="margin-top: 25px">
<button type="button" class="btn btn-sm btn-primary" [disabled]="loadingOps" (click)="loadOps()">
Next
</button>
</div>
</div>
<div *ngIf="channelStep === 2">
<label class="label-block">
Operator
<span class="text-muted ">The person selected here will be asked to approve or deny the bridge request.</span>
<select class="form-control form-control-sm" [(ngModel)]="op" [disabled]="requestingBridge">
<option *ngFor="let op of ops" [ngValue]="op">{{ op }}</option>
</select>
</label>
<div style="margin-top: 25px">
<button type="button" class="btn btn-sm btn-primary" [disabled]="requestingBridge" (click)="requestBridge()">
Request Bridge
</button>
</div>
</div>
</div>
</my-ibox>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
IRC Networks
</h5>
<div class="my-ibox-content">
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Channel</th>
<th>Network</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngIf="getChannels().length === 0">
<td colspan="3">No bridged channels</td>
</tr>
<tr *ngFor="let channel of getChannels()">
<td>
{{ channel.name }}
<span *ngIf="channel.pending" class="text-muted">(pending)</span>
</td>
<td>{{ channel.networkName }}</td>
<td class="actions-col">
<button type="button" class="btn btn-sm btn-outline-danger"
[disabled]="isUpdating || channel.pending"
(click)="removeChannel(channel)">
<i class="far fa-trash-alt"></i> Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
</my-ibox>
</ng-template>
</my-bridge-config>

View File

@ -0,0 +1,4 @@
.actions-col {
width: 120px;
text-align: center;
}

View File

@ -0,0 +1,133 @@
import { Component } from "@angular/core";
import { BridgeComponent } from "../bridge.component";
import { FE_IrcBridgeAvailableNetworks } from "../../../shared/models/irc";
import { IrcApiService } from "../../../shared/services/integrations/irc-api.service";
interface IrcConfig {
availableNetworks: FE_IrcBridgeAvailableNetworks;
links: {
[networkId: string]: {
channelName: string;
}[];
};
}
interface LocalChannel {
name: string;
networkId: string;
networkName: string;
pending: boolean;
}
@Component({
templateUrl: "irc.bridge.component.html",
styleUrls: ["irc.bridge.component.scss"],
})
export class IrcBridgeConfigComponent extends BridgeComponent<IrcConfig> {
public loadingOps = false;
public requestingBridge = false;
public channelStep = 1;
public networkId: string;
public channel = "";
public ops: string[];
public op: string;
public pending: LocalChannel[] = [];
constructor(private irc: IrcApiService) {
super("irc");
}
private resetForm() {
this.networkId = this.getNetworks()[0].id;
this.channel = "";
this.ops = [];
this.channelStep = 1;
}
public getNetworks(): { id: string, name: string }[] {
const ids = Object.keys(this.bridge.config.availableNetworks);
if (!this.networkId) setTimeout(() => this.networkId = ids[0], 0);
return ids.map(i => {
return {id: i, name: this.bridge.config.availableNetworks[i].name};
});
}
public loadOps() {
if (!this.channel.trim()) {
this.toaster.pop("warning", "Please enter a channel name");
return;
}
this.loadingOps = true;
this.irc.getOperators(this.networkId, this.channel).then(ops => {
this.ops = ops;
this.op = ops[0];
this.loadingOps = false;
this.channelStep = 2;
}).catch(err => {
console.error(err);
this.loadingOps = false;
this.toaster.pop("error", "Error loading channel operators");
});
}
public async requestBridge() {
this.requestingBridge = true;
const bridgeUserId = this.bridge.config.availableNetworks[this.networkId].bridgeUserId;
const memberEvent = await this.scalarClientApi.getMembershipState(this.roomId, bridgeUserId);
const isJoined = memberEvent && memberEvent.response && ["join", "invite"].indexOf(memberEvent.response.membership) !== -1;
if (!isJoined) await this.scalarClientApi.inviteUser(this.roomId, bridgeUserId);
try {
await this.scalarClientApi.setUserPowerLevel(this.roomId, bridgeUserId, 100);
} catch (err) {
console.error(err);
this.requestingBridge = false;
this.toaster.pop("error", "Failed to make the bridge an administrator", "Please ensure you are an 'Admin' for the room");
return;
}
this.irc.requestLink(this.roomId, this.networkId, this.channel, this.op).then(() => {
this.requestingBridge = false;
this.pending.push({
name: this.channel,
networkId: this.networkId,
networkName: this.bridge.config.availableNetworks[this.networkId].name,
pending: true,
});
this.resetForm();
this.toaster.pop("success", "Link requested!", "The operator selected will have to approve the bridge for it to work");
}).catch(err => {
console.error(err);
this.requestingBridge = false;
this.toaster.pop("error", "Failed to request a link");
});
}
public getChannels(): LocalChannel[] {
const channels: LocalChannel[] = [];
this.pending.forEach(p => channels.push(p));
for (const networkId of Object.keys(this.bridge.config.links)) {
const network = this.bridge.config.availableNetworks[networkId];
for (const channel of this.bridge.config.links[networkId]) {
channels.push({
networkId: networkId,
networkName: network.name,
name: channel.channelName,
pending: false,
});
}
}
return channels;
}
public removeChannel(channel: any) {
console.log(channel);
}
}

View File

@ -27,7 +27,6 @@ export interface FE_ComplexBot<T> extends FE_Integration {
}
export interface FE_Bridge<T> extends FE_Integration {
bridgeUserId: string;
config: T;
}

View File

@ -55,4 +55,10 @@ export interface CanSendEventResponse extends ScalarRoomResponse {
export interface RoomEncryptionStatusResponse extends ScalarRoomResponse {
response: boolean;
}
export interface SetPowerLevelResponse extends ScalarRoomResponse {
response: {
success: boolean;
};
}

View File

@ -0,0 +1,18 @@
import { Injectable } from "@angular/core";
import { Http } from "@angular/http";
import { AuthedApi } from "../authed-api";
@Injectable()
export class IrcApiService extends AuthedApi {
constructor(http: Http) {
super(http);
}
public getOperators(networkId: string, channelNoHash: string): Promise<string[]> {
return this.authedGet("/api/v1/dimension/irc/" + networkId + "/channel/" + channelNoHash + "/ops").map(r => r.json()).toPromise();
}
public requestLink(roomId: string, networkId: string, channelNoHash: string, op: string): Promise<any> {
return this.authedPost("/api/v1/dimension/irc/" + networkId + "/channel/" + channelNoHash + "/link/" + roomId, {op: op}).map(r => r.json()).toPromise();
}
}

View File

@ -3,8 +3,10 @@ import * as randomString from "random-string";
import {
CanSendEventResponse,
JoinRuleStateResponse,
MembershipStateResponse, RoomEncryptionStatusResponse,
MembershipStateResponse,
RoomEncryptionStatusResponse,
ScalarSuccessResponse,
SetPowerLevelResponse,
WidgetsResponse
} from "../../models/server-client-responses";
import { EditableWidget } from "../../models/widget";
@ -87,6 +89,14 @@ export class ScalarClientApiService {
});
}
public setUserPowerLevel(roomId: string, userId: string, powerLevel: number): Promise<SetPowerLevelResponse> {
return this.callAction("set_bot_power", {
room_id: roomId,
user_id: userId,
level: powerLevel,
});
}
private callAction(action, payload): Promise<any> {
let requestKey = randomString({length: 20});
return new Promise((resolve, reject) => {