diff --git a/src/bot.ts b/src/bot.ts index 4af4b861f7ccf270e5e1259f5eb75fa0aebccceb..e5a59600bf66c1e7d08686b72820e6f02fcfb6a7 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -91,17 +91,17 @@ export class DiscordBot { ) { // create handlers - this.provisioner = new Provisioner(store.roomStore); this.clientFactory = new DiscordClientFactory(store, config.auth); this.discordMsgProcessor = new DiscordMessageProcessor( new DiscordMessageProcessorOpts(config.bridge.domain, this), ); this.presenceHandler = new PresenceHandler(this); this.roomHandler = new MatrixRoomHandler(this, config, this.provisioner, bridge, store.roomStore); + this.channelSync = new ChannelSyncroniser(bridge, config, this, store.roomStore); + this.provisioner = new Provisioner(store.roomStore, this.channelSync); this.mxEventProcessor = new MatrixEventProcessor( new MatrixEventProcessorOpts(config, bridge, this), ); - this.channelSync = new ChannelSyncroniser(bridge, config, this, store.roomStore); this.discordCommandHandler = new DiscordCommandHandler(bridge, this); // init vars this.sentMessages = []; diff --git a/src/channelsyncroniser.ts b/src/channelsyncroniser.ts index c948f35da71df85004efa18ee4fbb802d0014d85..9f24d6f1d56fad4c5990ef3c7b1fcf53e417af83 100644 --- a/src/channelsyncroniser.ts +++ b/src/channelsyncroniser.ts @@ -17,7 +17,7 @@ limitations under the License. import * as Discord from "discord.js"; import { DiscordBot } from "./bot"; import { Util } from "./util"; -import { DiscordBridgeConfig } from "./config"; +import { DiscordBridgeConfig, DiscordBridgeConfigChannelDeleteOptions } from "./config"; import { Bridge } from "matrix-appservice-bridge"; import { Log } from "./log"; import { DbRoomStore, IRoomStoreEntry } from "./db/roomstore"; @@ -105,6 +105,20 @@ export class ChannelSyncroniser { } } + public async OnUnbridge(channel: Discord.Channel, roomId: string) { + try { + const entry = (await this.roomStore.getEntriesByMatrixId(roomId))[0]; + const opts = new DiscordBridgeConfigChannelDeleteOptions(); + opts.namePrefix = null; + opts.topicPrefix = null; + opts.ghostsLeave = true; + await this.handleChannelDeletionForRoom(channel as Discord.TextChannel, roomId, entry); + log.info(`Channel ${channel.id} has been unbridged.`); + } catch (e) { + log.error(`Failed to unbridge channel from room: ${e}`); + } + } + public async OnDelete(channel: Discord.Channel) { if (channel.type !== "text") { log.info(`Channel ${channel.id} was deleted but isn't a text channel, so ignoring.`); @@ -269,22 +283,23 @@ export class ChannelSyncroniser { private async handleChannelDeletionForRoom( channel: Discord.TextChannel, roomId: string, - entry: IRoomStoreEntry): Promise<void> { + entry: IRoomStoreEntry, + overrideOptions?: DiscordBridgeConfigChannelDeleteOptions): Promise<void> { log.info(`Deleting ${channel.id} from ${roomId}.`); const intent = await this.bridge.getIntent(); - const options = this.config.channel.deleteOptions; + const options = overrideOptions || this.config.channel.deleteOptions; const plumbed = entry.remote!.get("plumbed"); await this.roomStore.upsertEntry(entry); if (options.ghostsLeave) { for (const member of channel.members.array()) { - try { - const mIntent = await this.bot.GetIntentFromDiscordMember(member); - mIntent.leave(roomId); - log.info(`${member.id} left ${roomId}.`); - } catch (e) { - log.warn(`Failed to make ${member.id} leave `); - } + const mIntent = await this.bot.GetIntentFromDiscordMember(member); + // Not awaiting this because we want to do this in the background. + mIntent.leave(roomId).then(() => { + log.verbose(`${member.id} left ${roomId}.`); + }).catch(() => { + log.warn(`Failed to make ${member.id} leave.`); + }); } } if (options.namePrefix) { @@ -347,9 +362,7 @@ export class ChannelSyncroniser { } } } - // Unlist - // Remove entry await this.roomStore.removeEntriesByMatrixRoomId(roomId); } } diff --git a/src/config.ts b/src/config.ts index 637bc555fb28450a2b2feccb5eb584eaa2c114fe..166604317accbb842d349afe1ca26eb5c4500247 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,7 +86,7 @@ class DiscordBridgeConfigChannel { public deleteOptions = new DiscordBridgeConfigChannelDeleteOptions(); } -class DiscordBridgeConfigChannelDeleteOptions { +export class DiscordBridgeConfigChannelDeleteOptions { public namePrefix: string | null = null; public topicPrefix: string | null = null; public disableMessaging: boolean = false; diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts index 1ca4ae0862ab64cdae3d123c1c3124a4adda698d..c04230e0fa56c75046a628935f598454b7c6fc7c 100644 --- a/src/db/roomstore.ts +++ b/src/db/roomstore.ts @@ -18,6 +18,7 @@ import { IDatabaseConnector } from "./connector"; import { Util } from "../util"; import * as uuid from "uuid/v4"; +import { Postgres } from "./postgres"; const log = new Log("DbRoomStore"); @@ -47,8 +48,8 @@ interface IRemoteRoomDataLazy { } export class RemoteStoreRoom { - public data: IRemoteRoomData; - constructor(public readonly roomId: string, data: IRemoteRoomData) { + public data: IRemoteRoomDataLazy; + constructor(public readonly roomId: string, data: IRemoteRoomDataLazy) { for (const k of ["discord_guild", "discord_channel", "discord_name", "discord_topic", "discord_iconurl", "discord_iconurl_mxc", "discord_type"]) { data[k] = typeof(data[k]) === "number" ? String(data[k]) : data[k] || null; @@ -92,7 +93,6 @@ const ENTRY_CACHE_LIMETIME = 30000; export class DbRoomStore { private entriesMatrixIdCache: Map<string, {e: IRoomStoreEntry[], ts: number}>; - constructor(private db: IDatabaseConnector) { this.entriesMatrixIdCache = new Map(); } @@ -244,6 +244,10 @@ export class DbRoomStore { } public async getEntriesByRemoteRoomData(data: IRemoteRoomDataLazy): Promise<IRoomStoreEntry[]> { + Object.keys(data).filter((k) => typeof(data[k]) === "boolean").forEach((k) => { + data[k] = Number(data[k]); + }); + const whereClaues = Object.keys(data).map((key) => { return `${key} = $${key}`; }).join(" AND "); diff --git a/src/discordcommandhandler.ts b/src/discordcommandhandler.ts index 8f3804c640c82665c946dfd64adf5a0642ae7b2d..c27bb819b081893e10e9ef4bd8679cad30a76945 100644 --- a/src/discordcommandhandler.ts +++ b/src/discordcommandhandler.ts @@ -18,6 +18,10 @@ import { DiscordBot } from "./bot"; import * as Discord from "discord.js"; import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; import { Bridge } from "matrix-appservice-bridge"; +import { Log } from "./log"; + +const log = new Log("DiscordCommandHandler"); + export class DiscordCommandHandler { constructor( private bridge: Bridge, @@ -78,6 +82,12 @@ export class DiscordCommandHandler { permission: "BAN_MEMBERS", run: this.ModerationActionGenerator(chan, "unban"), }, + unbridge: { + description: "Unbridge matrix rooms from this channel", + params: [], + permission: ["MANAGE_WEBHOOKS", "MANAGE_CHANNELS"], + run: async () => this.UnbridgeChannel(chan), + }, }; const parameters: ICommandParameters = { @@ -91,8 +101,11 @@ export class DiscordCommandHandler { }, }; - const permissionCheck: CommandPermissonCheck = async (permission) => { - return msg.member.hasPermission(permission as Discord.PermissionResolvable); + const permissionCheck: CommandPermissonCheck = async (permission: string|string[]) => { + if (!Array.isArray(permission)) { + permission = [permission]; + } + return permission.every((p) => msg.member.hasPermission(p as Discord.PermissionResolvable)); }; const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck); @@ -131,4 +144,19 @@ export class DiscordCommandHandler { return `${action} ${name}`; }; } + + private async UnbridgeChannel(channel: Discord.TextChannel): Promise<string> { + try { + await this.discord.Provisioner.UnbridgeChannel(channel); + return "This channel has been unbridged"; + } catch (err) { + if (err.message === "Channel is not bridged") { + return "This channel is not bridged to a plumbed matrix room"; + } + log.error("Error while unbridging room " + channel.id); + log.error(err); + return "There was an error unbridging this room. " + + "Please try again later or contact the bridge operator."; + } + } } diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts index b9e00a9cbfa7237d11361aa19c512b761105bd3c..792dc2633b49d5a0c5e0dfd530d0fbd149906391 100644 --- a/src/matrixcommandhandler.ts +++ b/src/matrixcommandhandler.ts @@ -23,6 +23,7 @@ import { Provisioner } from "./provisioner"; import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; import * as Discord from "discord.js"; import * as markdown from "discord-markdown"; +import { RemoteStoreRoom } from "./db/roomstore"; const log = new Log("MatrixCommandHandler"); /* tslint:disable:no-magic-numbers */ @@ -45,15 +46,11 @@ export class MatrixCommandHandler { public async HandleInvite(event: IMatrixEvent) { log.info(`Received invite for ${event.state_key} in room ${event.room_id}`); - if (event.state_key === this.discord.GetBotId()) { - log.info("Accepting invite for bridge bot"); - await this.bridge.getIntent().joinRoom(event.room_id); - this.botJoinedRooms.add(event.room_id); - } + await this.bridge.getIntent().join(event.room_id); + this.botJoinedRooms.add(event.room_id); } public async Process(event: IMatrixEvent, context: BridgeContext) { - const intent = this.bridge.getIntent(); if (!(await this.isBotInRoom(event.room_id))) { log.warn(`Bot is not in ${event.room_id}. Ignoring command`); return; @@ -122,15 +119,19 @@ export class MatrixCommandHandler { subcat: "m.room.power_levels", }, run: async () => { - const remoteRoom = context.rooms.remote; + const remoteRoom = context.rooms.remote as RemoteStoreRoom; if (!remoteRoom) { return "This room is not bridged."; } if (!remoteRoom.data.plumbed) { return "This room cannot be unbridged."; } + const res = await this.discord.LookupRoom( + remoteRoom.data.discord_guild!, + remoteRoom.data.discord_channel!, + ); try { - await this.provisioner.UnbridgeRoom(remoteRoom); + await this.provisioner.UnbridgeChannel(res.channel, event.room_id); return "This room has been unbridged"; } catch (err) { log.error("Error while unbridging room " + event.room_id); diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 7c7f6a516ccfd7114329f177280c2429eefb76ce..3e39518ed5680ab4b38ac8355d2b90b28882b999 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -81,7 +81,7 @@ export class MatrixEventProcessor { if ( event.type === "m.room.member" && event.content!.membership === "invite" && - event.state_key === this.discord.GetBotId() + event.state_key === this.bridge.getClientFactory()._botUserId ) { await this.mxCommandHandler.HandleInvite(event); return; diff --git a/src/provisioner.ts b/src/provisioner.ts index c5b53068274d89b3e6644dce70104cfb4bd4e98b..46b7624f102c5652f444b13e7950882820855e7c 100644 --- a/src/provisioner.ts +++ b/src/provisioner.ts @@ -14,35 +14,58 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - Bridge, - RemoteRoom, - MatrixRoom, -} from "matrix-appservice-bridge"; import * as Discord from "discord.js"; -import { DbRoomStore } from "./db/roomstore"; +import { DbRoomStore, RemoteStoreRoom, MatrixStoreRoom } from "./db/roomstore"; +import { ChannelSyncroniser } from "./channelsyncroniser"; +import { Log } from "./log"; const PERMISSION_REQUEST_TIMEOUT = 300000; // 5 minutes +const log = new Log("Provisioner"); + export class Provisioner { private pendingRequests: Map<string, (approved: boolean) => void> = new Map(); // [channelId]: resolver fn - constructor(private roomStore: DbRoomStore) { } + constructor(private roomStore: DbRoomStore, private channelSync: ChannelSyncroniser) { } public async BridgeMatrixRoom(channel: Discord.TextChannel, roomId: string) { - const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}_bridged`); - remote.set("discord_type", "text"); - remote.set("discord_guild", channel.guild.id); - remote.set("discord_channel", channel.id); - remote.set("plumbed", true); - - const local = new MatrixRoom(roomId); + const remote = new RemoteStoreRoom(`discord_${channel.guild.id}_${channel.id}_bridged`, { + discord_channel: channel.id, + discord_guild: channel.guild.id, + discord_type: "text", + plumbed: true, + }); + + const local = new MatrixStoreRoom(roomId); return this.roomStore.linkRooms(local, remote); } - public async UnbridgeRoom(remoteRoom: RemoteRoom) { - return this.roomStore.removeEntriesByRemoteRoomId(remoteRoom.getId()); + public async UnbridgeChannel(channel: Discord.TextChannel, rId?: string) { + const roomsRes = await this.roomStore.getEntriesByRemoteRoomData({ + discord_channel: channel.id, + discord_guild: channel.guild.id, + plumbed: true, + }); + if (roomsRes.length === 0) { + throw Error("Channel is not bridged"); + } + const remoteRoom = roomsRes[0].remote as RemoteStoreRoom; + let roomsToUnbridge: string[] = []; + if (rId) { + roomsToUnbridge = [rId]; + } else { + // Kill em all. + roomsToUnbridge = roomsRes.map((entry) => entry.matrix!.roomId); + } + await Promise.all(roomsToUnbridge.map( async (roomId) => { + try { + await this.channelSync.OnUnbridge(channel, roomId); + } catch (ex) { + log.error(`Failed to cleanly unbridge ${channel.id} ${channel.guild} from ${roomId}`, ex); + } + })); + await this.roomStore.removeEntriesByRemoteRoomId(remoteRoom.getId()); } public async AskBridgePermission( diff --git a/src/util.ts b/src/util.ts index 6edce065c7dcc17bc6830f89226b681726367d8e..3cbf8b381baa00506a8e09d64cd00427c2f9d850 100644 --- a/src/util.ts +++ b/src/util.ts @@ -29,7 +29,7 @@ const HTTP_OK = 200; import { Log } from "./log"; const log = new Log("Util"); -type PERMISSIONTYPES = any; // tslint:disable-line no-any +type PERMISSIONTYPES = any | any[]; // tslint:disable-line no-any export interface ICommandAction { description?: string; @@ -299,7 +299,6 @@ export class Util { permissionCheck?: CommandPermissonCheck, ): Promise<string> { const {command, args} = Util.MsgToArgs(msg, prefix); - if (command === "help") { return await Util.HandleHelpCommand(prefix, actions, parameters, args, permissionCheck); } @@ -336,9 +335,9 @@ export class Util { const retStr = await action.run(params); return retStr; } catch (e) { - return `**ERROR:** ${e.message}`; log.error("Error processing command"); log.error(e); + return `**ERROR:** ${e.message}`; } } diff --git a/src/workers/WorkerCom.ts b/src/workers/WorkerCom.ts new file mode 100644 index 0000000000000000000000000000000000000000..68b7a1b4213d0f3bfa12be064a4127c1fca2457e --- /dev/null +++ b/src/workers/WorkerCom.ts @@ -0,0 +1,85 @@ +// import { Worker } from "worker_threads"; +// import { DiscordBot } from "../bot"; +// import { Bridge } from "matrix-appservice-bridge"; +// import { Log } from "../log"; + +// const log = new Log("WorkerCom"); + +// export interface IWorkerCmd { +// type: string; +// id: string; +// } + +// export interface IWorkerResult extends IWorkerCmd { +// type: "res"; +// error: any|null; +// result: any|null; +// } + +// export interface IWorkerCmdClose extends IWorkerCmd { +// type: "close"; +// reason: string; +// error: string|undefined; +// } + +// export interface IWorkerIntentAction extends IWorkerCmd { +// type: "intent_action"; +// useClient: boolean; +// matrixId: string|undefined; +// discordId: string|undefined; +// function: string; +// args: any[]; +// } + +// export abstract class WorkerCom { +// constructor(protected worker: Worker, protected discordBot: DiscordBot|null = null, protected bridge: Bridge|null = null) { +// worker.on("message", (value: IWorkerCmd) => { +// this.onMessage(value).then((result) => { +// this.worker.postMessage({ +// id: value.id, +// result, +// error: null, +// } as IWorkerResult); +// }).catch((ex) => { +// this.worker.postMessage({ +// id: value.id, +// result: null, +// error: ex, +// } as IWorkerResult); +// }) +// }); +// worker.on("error", this.onError.bind(this)); + +// } + +// protected async onMessage(value: IWorkerCmd) { +// if (value.type === "close") { +// const close = value as IWorkerCmdClose; +// log.warn(`Worker is closing: ${close.reason} ${close.error}`); +// } else if (value.type === "intent_action") { +// const intentAction = value as IWorkerIntentAction; +// let intent; +// if (intentAction.matrixId) { +// intent = this.bridge!.getIntent(intentAction.matrixId); +// } else if (intentAction.discordId) { +// intent = this.discordBot!.GetIntentFromDiscordMember(intentAction.discordId); +// } else { +// log.warn("Tried to do an intent_action but no IDs were defined"); +// return; +// } +// if (intentAction.useClient) { +// intent = intent.getClient(); +// } +// const func: () => any = intent[intentAction.function]; +// if (!func) { +// log.warn(`Tried to do an intent_action but ${func} is not a valid function`); +// return; +// } +// return await func.call(intent, intentAction.args); +// } +// } + +// protected onError(exitCode: number) { + +// } +// } \ No newline at end of file diff --git a/test/test_discordcommandhandler.ts b/test/test_discordcommandhandler.ts index f5851f4f4e1a5fb5bbdaa1f2632f61475ee3c89f..a66439530ac0a2a4cfc91791f9753858f3088125 100644 --- a/test/test_discordcommandhandler.ts +++ b/test/test_discordcommandhandler.ts @@ -31,6 +31,7 @@ let USERSJOINED = 0; let USERSKICKED = 0; let USERSBANNED = 0; let USERSUNBANNED = 0; +let ROOMSUNBRIDGED = 0; let MESSAGESENT: any = {}; let MARKED = -1; function createCH(opts: any = {}) { @@ -38,6 +39,7 @@ function createCH(opts: any = {}) { USERSKICKED = 0; USERSBANNED = 0; USERSUNBANNED = 0; + ROOMSUNBRIDGED = 0; MESSAGESENT = {}; MARKED = -1; const bridge = { @@ -66,6 +68,9 @@ function createCH(opts: any = {}) { MARKED = approved ? 1 : 0; return approved; }, + UnbridgeChannel: () => { + ROOMSUNBRIDGED++; + }, }, }; const discordCommandHndlr = (Proxyquire("../src/discordcommandhandler", { @@ -202,4 +207,21 @@ describe("DiscordCommandHandler", () => { await handler.Process(message); expect(MARKED).equals(0); }); + it("handles !matrix unbridge", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix unbridge", + member, + }; + await handler.Process(message); + expect(ROOMSUNBRIDGED).equals(1); + }); }); diff --git a/test/test_matrixcommandhandler.ts b/test/test_matrixcommandhandler.ts index 70359d79ea92db5e15064a97fed075edc9589f11..2ca7aec41469f3356b45feaf9108d2fbb6dced38 100644 --- a/test/test_matrixcommandhandler.ts +++ b/test/test_matrixcommandhandler.ts @@ -87,7 +87,7 @@ function createCH(opts: any = {}) { throw new Error("Test failed matrix bridge"); } }, - UnbridgeRoom: async () => { + UnbridgeChannel: async () => { if (opts.failUnbridge) { throw new Error("Test failed unbridge"); } @@ -202,24 +202,48 @@ describe("MatrixCommandHandler", () => { describe("!discord unbridge", () => { it("will unbridge", async () => { const handler: any = createCH(); - await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: true}})); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: true, + }, + }, + )); expect(MESSAGESENT.body).equals("This room has been unbridged"); }); it("will not unbridge if a link does not exist", async () => { const handler: any = createCH(); - const evt = await handler.Process(createEvent("!discord unbridge"), createContext()); + await handler.Process(createEvent("!discord unbridge"), createContext()); expect(MESSAGESENT.body).equals("This room is not bridged."); }); it("will not unbridge non-plumbed rooms", async () => { const handler: any = createCH(); - await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: false}})); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: false, + }, + }, + )); expect(MESSAGESENT.body).equals("This room cannot be unbridged."); }); it("will show error if unbridge fails", async () => { const handler: any = createCH({ failUnbridge: true, }); - await handler.Process(createEvent("!discord unbridge"), createContext({data: {plumbed: true}})); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: true, + }, + }, + )); expect(MESSAGESENT.body).to.contain("There was an error unbridging this room."); }); }); diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index 97f0fe7ae05c30fc199cc9d39d4f4df8f41fa7e7..dd1a5d2e543b6740a5079750921b3af2626d472d 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -103,6 +103,7 @@ function createMatrixEventProcessor(): MatrixEventProcessor { }, getClientFactory: () => { return { + _botUserId: "@botuser:localhost", getClientAs: () => { return mxClient; }, diff --git a/test/test_provisioner.ts b/test/test_provisioner.ts index 79f8f71734c2f3da382bd6a8c2093c4f7c90f235..4f595e40416734dd5de6e189b5630b9a2264ea00 100644 --- a/test/test_provisioner.ts +++ b/test/test_provisioner.ts @@ -29,7 +29,7 @@ const TIMEOUT_MS = 1000; describe("Provisioner", () => { describe("AskBridgePermission", () => { it("should fail to bridge a room that timed out", async () => { - const p = new Provisioner({} as any); + const p = new Provisioner({} as any, {} as any); const startAt = Date.now(); try { await p.AskBridgePermission( @@ -47,7 +47,7 @@ describe("Provisioner", () => { } }); it("should fail to bridge a room that was declined", async () => { - const p = new Provisioner({} as any); + const p = new Provisioner({} as any, {} as any); const promise = p.AskBridgePermission( new MockChannel("foo", "bar") as any, "Mark", @@ -63,7 +63,7 @@ describe("Provisioner", () => { }); it("should bridge a room that was approved", async () => { - const p = new Provisioner({} as any); + const p = new Provisioner({} as any, {} as any); const promise = p.AskBridgePermission( new MockChannel("foo", "bar") as any, "Mark",