diff --git a/src/bot.ts b/src/bot.ts index eb29b3174547a1ac91ab0c2c30d89f5b18c90437..639023864dece96fe1a10e795146c723225c7f5c 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -10,7 +10,7 @@ import { MatrixEventProcessor, MatrixEventProcessorOpts } from "./matrixeventpro import { PresenceHandler } from "./presencehandler"; import { Provisioner } from "./provisioner"; import { UserSyncroniser } from "./usersyncroniser"; -import { ChannelHandler } from "./channelhandler"; +import { ChannelSyncroniser } from "./channelsyncroniser"; import * as Discord from "discord.js"; import * as log from "npmlog"; import * as Bluebird from "bluebird"; @@ -40,7 +40,7 @@ export class DiscordBot { private mxEventProcessor: MatrixEventProcessor; private presenceHandler: PresenceHandler; private userSync: UserSyncroniser; - private channelHandler: ChannelHandler; + private channelSync: ChannelSyncroniser; constructor(config: DiscordBridgeConfig, store: DiscordStore, private provisioner: Provisioner) { this.config = config; @@ -68,6 +68,10 @@ export class DiscordBot { return this.userSync; } + get ChannelSyncroniser(): ChannelSyncroniser { + return this.channelSync; + } + public GetIntentFromDiscordMember(member: Discord.GuildMember | Discord.User): any { return this.bridge.getIntentFromLocalpart(`_discord_${member.id}`); } @@ -85,9 +89,9 @@ export class DiscordBot { this.presenceHandler.EnqueueUser(newMember.user); }); } - this.channelHandler = new ChannelHandler(this.bridge, this.config, this); - client.on("channelUpdate", (_, newChannel) => { this.UpdateRooms(newChannel); }); - client.on("channelDelete", (channel) => { this.channelHandler.HandleChannelDelete(channel); }); + this.channelSync = new ChannelSyncroniser(this.bridge, this.config, this); + client.on("channelUpdate", (_, newChannel) => { this.channelSync.OnUpdate(newChannel); }); + client.on("channelDelete", (channel) => { this.channelSync.OnDelete(channel); }); client.on("messageDelete", (msg) => { this.DeleteDiscordMessage(msg); }); client.on("messageUpdate", (oldMessage, newMessage) => { this.OnMessageUpdate(oldMessage, newMessage); }); client.on("message", (msg) => { Bluebird.delay(MSG_PROCESS_DELAY).then(() => { @@ -346,7 +350,7 @@ export class DiscordBot { log.info("DiscordBot", `Updating ${discordChannel.id}`); const textChan = (<Discord.TextChannel> discordChannel); const roomStore = this.bridge.getRoomStore(); - this.channelHandler.GetRoomIdsFromChannel(textChan).then((rooms) => { + this.channelSync.GetRoomIdsFromChannel(textChan).then((rooms) => { return roomStore.getEntriesByMatrixIds(rooms).then( (entries) => { return Object.keys(entries).map((key) => entries[key]); }); @@ -389,7 +393,7 @@ export class DiscordBot { private async SendMatrixMessage(matrixMsg: MessageProcessorMatrixResult, chan: Discord.Channel, guild: Discord.Guild, author: Discord.User, msgID: string): Promise<boolean> { - const rooms = await this.channelHandler.GetRoomIdsFromChannel(chan); + const rooms = await this.channelSync.GetRoomIdsFromChannel(chan); const intent = this.GetIntentFromDiscordMember(author); rooms.forEach((room) => { @@ -413,7 +417,7 @@ export class DiscordBot { } private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { - this.channelHandler.GetRoomIdsFromChannel(channel).then((rooms) => { + this.channelSync.GetRoomIdsFromChannel(channel).then((rooms) => { const intent = this.GetIntentFromDiscordMember(user); return Promise.all(rooms.map((room) => { return intent.sendTyping(room, isTyping); @@ -471,7 +475,7 @@ export class DiscordBot { // Update presence because sometimes discord misses people. this.userSync.OnUpdateUser(msg.author).then(() => { - return this.channelHandler.GetRoomIdsFromChannel(msg.channel).catch((err) => { + return this.channelSync.GetRoomIdsFromChannel(msg.channel).catch((err) => { log.verbose("DiscordBot", "No bridged rooms to send message to. Oh well."); return null; }); diff --git a/src/channelhandler.ts b/src/channelhandler.ts deleted file mode 100644 index 69b8f9643c5e7943f2f1b18c6e4ddc0c74cdfbd9..0000000000000000000000000000000000000000 --- a/src/channelhandler.ts +++ /dev/null @@ -1,142 +0,0 @@ -import {Channel, TextChannel} from "discord.js"; -import * as Discord from "discord.js"; -import * as log from "npmlog"; -import {DiscordBot} from "./bot"; -import {DiscordBridgeConfig} from "./config"; -import { Bridge, RoomBridgeStore } from "matrix-appservice-bridge"; - -const POWER_LEVEL_MESSAGE_TALK = 50; - -export class ChannelHandler { - - private roomStore: RoomBridgeStore; - constructor( - private bridge: Bridge, - private config: DiscordBridgeConfig, - private bot: DiscordBot) { - this.roomStore = this.bridge.getRoomStore(); - } - - public async HandleChannelDelete(channel: Channel) { - if (channel.type !== "text") { - log.info("ChannelHandler", `Channel ${channel.id} was deleted but isn't a text channel, so ignoring.`); - return; - } - log.info("ChannelHandler", `Channel ${channel.id} has been deleted.`); - let roomids; - let entries; - try { - roomids = await this.GetRoomIdsFromChannel(channel); - entries = await this.roomStore.getEntriesByMatrixIds(roomids); - } catch (e) { - log.warn("ChannelHandler", `Couldn't find roomids for deleted channel ${channel.id}`); - return; - } - for (const roomid of roomids){ - try { - await this.handleChannelDeletionForRoom(channel as Discord.TextChannel, roomid, entries[roomid][0]); - } catch (e) { - log.error("ChannelHandler", `Failed to delete channel from room: ${e}`); - } - } - } - - public GetRoomIdsFromChannel(channel: Discord.Channel): Promise<string[]> { - return this.roomStore.getEntriesByRemoteRoomData({ - discord_channel: channel.id, - }).then((rooms) => { - if (rooms.length === 0) { - log.verbose("ChannelHandler", `Couldn"t find room(s) for channel ${channel.id}.`); - return Promise.reject("Room(s) not found."); - } - return rooms.map((room) => room.matrix.getId() as string); - }); - } - - private async handleChannelDeletionForRoom( - channel: Discord.TextChannel, - roomId: string, - entry: any): Promise<void> { - log.info("ChannelHandler", `Deleting ${channel.id} from ${roomId}.`); - const intent = await this.bridge.getIntent(); - const options = this.config.channel.deleteOptions; - const plumbed = entry.remote.get("plumbed"); - - 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("ChannelHandler", `${member.id} left ${roomId}.`); - } catch (e) { - log.warn("ChannelHandler", `Failed to make ${member.id} leave `); - } - } - } - if (options.namePrefix) { - try { - const name = await intent.getClient().getStateEvent(roomId, "m.room.name"); - name.name = options.namePrefix + name.name; - await intent.getClient().setRoomName(roomId, name.name); - } catch (e) { - log.error("ChannelHandler", `Failed to set name of room ${roomId} ${e}`); - } - } - if (options.topicPrefix) { - try { - const topic = await intent.getClient().getStateEvent(roomId, "m.room.topic"); - topic.topic = options.topicPrefix + topic.topic; - await intent.getClient().setRoomTopic(roomId, topic.topic); - } catch (e) { - log.error("ChannelHandler", `Failed to set topic of room ${roomId} ${e}`); - } - } - - if (plumbed !== true) { - if (options.unsetRoomAlias) { - try { - const alias = "#_" + entry.remote.roomId + ":" + this.config.bridge.domain; - const canonicalAlias = await intent.getClient().getStateEvent(roomId, "m.room.canonical_alias"); - if (canonicalAlias.alias === alias) { - await intent.getClient().sendStateEvent(roomId, "m.room.canonical_alias", {}); - } - await intent.getClient().deleteAlias(alias); - } catch (e) { - log.error("ChannelHandler", `Couldn't remove alias of ${roomId} ${e}`); - } - } - - if (options.unlistFromDirectory) { - try { - await intent.getClient().setRoomDirectoryVisibility(roomId, "private"); - } catch (e) { - log.error("ChannelHandler", `Couldn't remove ${roomId} from room directory ${e}`); - } - - } - - if (options.setInviteOnly) { - try { - await intent.getClient().sendStateEvent(roomId, "m.room.join_rules", {join_role: "invite"}); - } catch (e) { - log.error("ChannelHandler", `Couldn't set ${roomId} to private ${e}`); - } - } - - if (options.disableMessaging) { - try { - const state = await intent.getClient().getStateEvent(roomId, "m.room.power_levels"); - state.events_default = POWER_LEVEL_MESSAGE_TALK; - await intent.getClient().sendStateEvent(roomId, "m.room.power_levels", state); - } catch (e) { - log.error("ChannelHandler", `Couldn't disable messaging for ${roomId} ${e}`); - } - } - } - // Unlist - - // Remove entry - await this.roomStore.removeEntriesByMatrixRoomId(roomId); - } -} diff --git a/src/channelsyncroniser.ts b/src/channelsyncroniser.ts new file mode 100644 index 0000000000000000000000000000000000000000..fec9ee1553a37ade16fd2c37192723ce47ee7ea9 --- /dev/null +++ b/src/channelsyncroniser.ts @@ -0,0 +1,292 @@ +import * as Discord from "discord.js"; +import * as log from "npmlog"; +import { DiscordBot } from "./bot"; +import { Util } from "./util"; +import { DiscordBridgeConfig } from "./config"; +import { Bridge, RoomBridgeStore } from "matrix-appservice-bridge"; + +const POWER_LEVEL_MESSAGE_TALK = 50; + +const DEFAULT_CHANNEL_STATE = { + id: null, + mxChannels: [], +}; + +const DEFAULT_SINGLECHANNEL_STATE = { + mxid: null, + name: null, // nullable + topic: null, // nullable + iconUrl: null, // nullable + iconId: null, + removeIcon: false, +}; + +export interface ISingleChannelState { + mxid: string; + name: string; // nullable + topic: string; // nullable + iconUrl: string; // nullable + iconId: string; // nullable + removeIcon: boolean; +}; + +export interface IChannelState { + id: string; + mxChannels: ISingleChannelState[]; +}; + +export class ChannelSyncroniser { + + private roomStore: RoomBridgeStore; + constructor( + private bridge: Bridge, + private config: DiscordBridgeConfig, + private bot: DiscordBot) { + this.roomStore = this.bridge.getRoomStore(); + } + + public async OnUpdate(channel: Discord.Channel) { + if (channel.type !== "text") { + return; // Not supported for now + } + const channelState = await this.GetChannelUpdateState(channel as Discord.TextChannel); + try { + await this.ApplyStateToChannel(channelState); + } catch (e) { + log.error("ChannelSync", "Failed to update channels", e); + } + } + + private async ApplyStateToChannel(channelsState: IChannelState) { + const intent = this.bridge.getIntent(); + let iconMxcUrl = null; + channelsState.mxChannels.forEach(async (channelState) => { + let roomUpdated = false; + const remoteRoom = (await this.roomStore.getEntriesByMatrixId(channelState.mxid))[0]; + + if (channelState.name !== null) { + log.verbose("ChannelSync", `Updating channelname for ${channelState.mxid} to "${channelState.name}"`); + await intent.setRoomName(channelState.mxid, channelState.name); + remoteRoom.remote.set("discord_name", channelState.name); + roomUpdated = true; + } + + if (channelState.topic !== null) { + log.verbose("ChannelSync", `Updating channeltopic for ${channelState.mxid} to "${channelState.topic}"`); + await intent.setRoomTopic(channelState.mxid, channelState.topic); + remoteRoom.remote.set("discord_topic", channelState.topic); + roomUpdated = true; + } + + if (channelState.iconUrl !== null) { + log.verbose("ChannelSync", `Updating icon_url for ${channelState.mxid} to "${channelState.iconUrl}"`); + if (iconMxcUrl === null) { + const iconMxc = await Util.UploadContentFromUrl( + channelState.iconUrl, + intent, + channelState.iconId, + ); + iconMxcUrl = iconMxc.mxcUrl; + } + await intent.setRoomAvatar(channelState.mxid, iconMxcUrl); + remoteRoom.remote.set("discord_iconurl", channelState.iconUrl); + remoteRoom.remote.set("discord_iconurl_mxc", iconMxcUrl); + roomUpdated = true; + } + + if (channelState.removeIcon) { + log.verbose("ChannelSync", `Clearing icon_url for ${channelState.mxid}`); + await intent.setRoomAvatar(channelState.mxid, null); + remoteRoom.remote.set("discord_iconurl", null); + remoteRoom.remote.set("discord_iconurl_mxc", null); + roomUpdated = true; + } + + if (roomUpdated) { + await this.roomStore.upsertEntry(remoteRoom); + } + }); + } + + public async OnDelete(channel: Discord.Channel) { + if (channel.type !== "text") { + log.info("ChannelSync", `Channel ${channel.id} was deleted but isn't a text channel, so ignoring.`); + return; + } + log.info("ChannelSync", `Channel ${channel.id} has been deleted.`); + let roomids; + let entries; + try { + roomids = await this.GetRoomIdsFromChannel(channel); + entries = await this.roomStore.getEntriesByMatrixIds(roomids); + } catch (e) { + log.warn("ChannelSync", `Couldn't find roomids for deleted channel ${channel.id}`); + return; + } + for (const roomid of roomids){ + try { + await this.handleChannelDeletionForRoom(channel as Discord.TextChannel, roomid, entries[roomid][0]); + } catch (e) { + log.error("ChannelSync", `Failed to delete channel from room: ${e}`); + } + } + } + + public GetRoomIdsFromChannel(channel: Discord.Channel): Promise<string[]> { + return this.roomStore.getEntriesByRemoteRoomData({ + discord_channel: channel.id, + }).then((rooms) => { + if (rooms.length === 0) { + log.verbose("ChannelSync", `Couldn't find room(s) for channel ${channel.id}.`); + return Promise.reject("Room(s) not found."); + } + return rooms.map((room) => room.matrix.getId() as string); + }); + } + + private async GetChannelUpdateState(channel: Discord.TextChannel): Promise<IChannelState> { + log.verbose("ChannelSync", `State update request for ${channel.id}`); + const channelState = Object.assign({}, DEFAULT_CHANNEL_STATE, { + id: channel.id, + mxChannels: [], + }); + const patternMap = { + name: "#"+channel.name, + guild: channel.guild.name, + }; + let name = this.config.channel.namePattern; + for (const p in patternMap) { + name = name.replace(new RegExp(":"+p, "g"), patternMap[p]); + } + const topic = channel.topic; + const icon = channel.guild.icon; + let iconUrl = null; + if (icon) { + iconUrl = `https://cdn.discordapp.com/icons/${channel.guild.id}/${icon}.png`; + } + + const remoteRooms = await this.roomStore.getEntriesByRemoteRoomData({discord_channel: channel.id}); + if (remoteRooms.length === 0) { + log.verbose("ChannelSync", `Could not find any channels in room store.`); + return channelState; + } + remoteRooms.forEach((remoteRoom) => { + const mxid = remoteRoom.matrix.getId(); + const singleChannelState = Object.assign({}, DEFAULT_SINGLECHANNEL_STATE, { + mxid: mxid, + }); + + const oldName = remoteRoom.remote.get("discord_name"); + if (remoteRoom.remote.get("update_name") && oldName !== name) { + log.verbose("ChannelSync", `Channel ${mxid} name should be updated`); + singleChannelState.name = name; + } + + const oldTopic = remoteRoom.remote.get("discord_topic"); + if (remoteRoom.remote.get("update_topic") && oldTopic !== topic) { + log.verbose("ChannelSync", `Channel ${mxid} topic should be updated`); + singleChannelState.topic = topic; + } + + const oldIconUrl = remoteRoom.remote.get("discord_iconurl"); + if (remoteRoom.remote.get("update_icon") && oldIconUrl !== iconUrl) { + log.verbose("ChannelSync", `Channel ${mxid} icon should be updated`); + if (iconUrl !== null) { + singleChannelState.iconUrl = iconUrl; + singleChannelState.iconId = icon; + } else { + singleChannelState.removeIcon = oldIconUrl !== null; + } + } + channelState.mxChannels.push(singleChannelState); + }); + return channelState; + } + + private async handleChannelDeletionForRoom( + channel: Discord.TextChannel, + roomId: string, + entry: any): Promise<void> { + log.info("ChannelSync", `Deleting ${channel.id} from ${roomId}.`); + const intent = await this.bridge.getIntent(); + const options = this.config.channel.deleteOptions; + const plumbed = entry.remote.get("plumbed"); + + 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("ChannelSync", `${member.id} left ${roomId}.`); + } catch (e) { + log.warn("ChannelSync", `Failed to make ${member.id} leave `); + } + } + } + if (options.namePrefix) { + try { + const name = await intent.getClient().getStateEvent(roomId, "m.room.name"); + name.name = options.namePrefix + name.name; + await intent.getClient().setRoomName(roomId, name.name); + } catch (e) { + log.error("ChannelSync", `Failed to set name of room ${roomId} ${e}`); + } + } + if (options.topicPrefix) { + try { + const topic = await intent.getClient().getStateEvent(roomId, "m.room.topic"); + topic.topic = options.topicPrefix + topic.topic; + await intent.getClient().setRoomTopic(roomId, topic.topic); + } catch (e) { + log.error("ChannelSync", `Failed to set topic of room ${roomId} ${e}`); + } + } + + if (plumbed !== true) { + if (options.unsetRoomAlias) { + try { + const alias = "#_" + entry.remote.roomId + ":" + this.config.bridge.domain; + const canonicalAlias = await intent.getClient().getStateEvent(roomId, "m.room.canonical_alias"); + if (canonicalAlias.alias === alias) { + await intent.getClient().sendStateEvent(roomId, "m.room.canonical_alias", {}); + } + await intent.getClient().deleteAlias(alias); + } catch (e) { + log.error("ChannelSync", `Couldn't remove alias of ${roomId} ${e}`); + } + } + + if (options.unlistFromDirectory) { + try { + await intent.getClient().setRoomDirectoryVisibility(roomId, "private"); + } catch (e) { + log.error("ChannelSync", `Couldn't remove ${roomId} from room directory ${e}`); + } + + } + + if (options.setInviteOnly) { + try { + await intent.getClient().sendStateEvent(roomId, "m.room.join_rules", {join_role: "invite"}); + } catch (e) { + log.error("ChannelSync", `Couldn't set ${roomId} to private ${e}`); + } + } + + if (options.disableMessaging) { + try { + const state = await intent.getClient().getStateEvent(roomId, "m.room.power_levels"); + state.events_default = POWER_LEVEL_MESSAGE_TALK; + await intent.getClient().sendStateEvent(roomId, "m.room.power_levels", state); + } catch (e) { + log.error("ChannelSync", `Couldn't disable messaging for ${roomId} ${e}`); + } + } + } + // Unlist + + // Remove entry + await this.roomStore.removeEntriesByMatrixRoomId(roomId); + } +} diff --git a/src/config.ts b/src/config.ts index 2e3b3b92773e87cd66bb51d3e3f2fa71dce8a6e5..5202c03195b0c31e4f138fc362ea11cf8bec0108 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,6 +57,7 @@ class DiscordBridgeConfigRoom { } class DiscordBridgeConfigChannel { + public namePattern: string = "[Discord] :guild :name"; public deleteOptions = new DiscordBridgeConfigChannelDeleteOptions(); } diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index 41fc7be899ec6b12528e5a1de771103531158316..2c2cb870cfe73e9dee23cce9de1ffd2e558d7d8a 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -72,6 +72,7 @@ export class MatrixRoomHandler { roomId, "public", ); + await this.discord.ChannelSyncroniser.OnUpdate(channel); let promiseChain: Bluebird<any> = Bluebird.resolve(); /* We delay the joins to give some implementations a chance to breathe */ // Join a whole bunch of users. @@ -412,11 +413,10 @@ export class MatrixRoomHandler { remote.set("discord_channel", channel.id); remote.set("update_name", true); remote.set("update_topic", true); + remote.set("update_icon", true); const creationOpts = { visibility: this.config.room.defaultVisibility, room_alias_name: alias, - name: `[Discord] ${channel.guild.name} #${channel.name}`, - topic: channel.topic ? channel.topic : "", initial_state: [ { type: "m.room.join_rules", diff --git a/test/test_channelhandler.ts b/test/test_channelsyncroniser.ts similarity index 90% rename from test/test_channelhandler.ts rename to test/test_channelsyncroniser.ts index 5ec632ae3932d808ef78a266cfa323a6e64543d2..b0875e269518a4e60212de2742f0d36b66f77dd2 100644 --- a/test/test_channelhandler.ts +++ b/test/test_channelsyncroniser.ts @@ -4,7 +4,7 @@ import * as log from "npmlog"; import * as Discord from "discord.js"; import * as Proxyquire from "proxyquire"; -import { ChannelHandler } from "../src/channelhandler"; +import { ChannelSyncroniser } from "../src/channelsyncroniser"; import { DiscordBot } from "../src/bot"; import { MockGuild } from "./mocks/guild"; import { MockMember } from "./mocks/member"; @@ -26,7 +26,7 @@ const bridge = { const config = new DiscordBridgeConfig(); -describe("ChannelHandler", () => { +describe("ChannelSyncroniser", () => { describe("HandleChannelDelete", () => { it("will not delete non-text channels", () => {