diff --git a/README.md b/README.md index 758b827cbbd030f9dd01324a8541ce23a396fde1..b0f3a223948f73e57902afff81b01c551093c72d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,13 @@ Please also be aware that this is an unoffical project worked on in my (Half-Sho * Run ``node build/src/discordas.js -r -u "http://localhost:9005/" -c config.yaml`` * Modify your HSs appservices config so that it includes the generated file. +#### 3PID Protocol Support + +This bridge support searching for rooms within networks via the 3pid system +used in clients like [Riot](https://riot.im). However, it requires a small manual change +to your registration file. Add ``protocols: ["discord"]`` to the end and restart both your bridge +and synapse. Any new servers/guilds you bridge should show up in the network list on Riot and other clients. + ### Setting up Discord * Create a new application via https://discordapp.com/developers/applications/me/create @@ -43,9 +50,6 @@ Please also be aware that this is an unoffical project worked on in my (Half-Sho In a vague order of what is coming up next - [x] Group messages - - [ ] Direct messages - - [ ] Recieving - - [ ] Initiating - Matrix -> Discord - [x] Text content - [x] Image content @@ -63,7 +67,9 @@ In a vague order of what is coming up next - [x] Rooms - [ ] Users - [ ] Puppet a user's real Discord account. - - [ ] Rooms react to Discord updates + - [x] Sending messages + - [ ] Direct messages + - [x] Rooms react to Discord updates - [ ] Integrate Discord into existing rooms. - [ ] Manage channel from Matrix - [ ] Authorise admin rights from Discord to Matrix users diff --git a/config/config.sample.yaml b/config/config.sample.yaml index 9afb53742c488a905233dac05f4ca4cb71bfbf0b..af22a1841b458a7572d2e6b7d9167160825b15eb 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -5,3 +5,5 @@ auth: clientID: "12345" # Get from discord secret: "blah" botToken: "foobar" +logging: + level: "warn" #silly, verbose, info, http, warn, error diff --git a/config/config.schema.yaml b/config/config.schema.yaml index 798771b14f4ebc5eb57f82021970728d0055f2eb..14dbddd7230c4bc3cadc03889d19f6a44b200aed 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -20,3 +20,9 @@ properties: type: "string" botToken: type: "string" + logging: + type: "object" + required: ["level"] + properties: + level: + type: "string" diff --git a/docs/puppeting.md b/docs/puppeting.md new file mode 100644 index 0000000000000000000000000000000000000000..4ca9062b9c5d525ba614a5d3575fbecc005beb3b --- /dev/null +++ b/docs/puppeting.md @@ -0,0 +1,50 @@ +# Puppeting + +This docs describes the method to puppet yourself with the bridge, so you can +interact with the bridge as if you were using the real Discord client. This +has the benefits of (not all of these may be implemented): + * Talking as yourself, rather than as the bot. + * DM channels + * Able to use your Discord permissions, as well as joining rooms limited to + your roles as on Discord. + +## Caveats & Disclaimer + +Discord is currently __not__ offering any way to authenticate on behalf +of a user _and_ interact on their behalf. The OAuth system does not allow +remote access beyond reading information about the users. While [developers have +expressed a wish for this](https://feedback.discordapp.com/forums/326712-discord-dream-land/suggestions/16753837-support-custom-clients) +,it is my opinion that Discord are unlikely to support this any time soon. With +all this said, Discord will not be banning users or the bridge itself for acting +on the behalf of the user. + +Therefore while I loathe to do it, we have to store login tokens for *full +permissions* on the user's account (excluding things such as changing passwords + and e-mail which require re-authenication, thankfully). + +The tokens will be stored by the bridge and are valid until the user +changes their password, so please be careful not to give the token to anything +that you wouldn't trust with your password. + +I accept no responsibility if Discord ban your IP, Account or even your details on +their system. They have never given official support on custom clients (and + by extension, puppeting bridges). If you are in any doubt, stick to the + bot which is within the rules. + +## How to Puppet an Account +*2FA does not work with bridging, please do not try it.* + +* Go to [Discord](https://discordapp.com/channels/@me) on your *browser* and log + in if you haven't. +* Open the developer console (On Firefox/Chrome this is Shift+Control+C) +* Click Storage or Application if it is not already selected. +* On the left hand side there will be an option for "Local Storage", find this + and expand it and then click on the Discord option. +* Find the option for token on the right hand side and copy the value, excluding + the `"`s +* ~~Start a conversation with ``@_discord_bot:yourdomain`` on Matrix and send + the message "account.link Your_Token"~~ +* ~~The bridge should reply once it's managed to log you in.~~ +* Bot control has not been implemented yet, for now you will need to edit the + database and fill in user_tokens with your userId and token. +* Congratulations, you are now puppeted. diff --git a/package.json b/package.json index 5566300968bf18cd7cef48723e7aad801484f95e..59e7cfa3ab2b5a80256a8a86afa470e1ebbb6daf 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "coverage": "istanbul --include-all-sources cover -x build/src/discordas.js _mocha -- build/test/ -R spec", "build": "tsc", "start": "npm run-script build && node ./build/src/discordas.js -p 9005 -c config.yaml", - "getbotlink": "node ./tools/addbot.js" + "getbotlink": "node ./build/tools/addbot.js", + "adminme": "node ./build/tools/adminme.js" }, "repository": { "type": "git", @@ -29,14 +30,19 @@ }, "homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme", "dependencies": { + "@types/bluebird": "^3.0.37", "@types/node": "^7.0.5", + "@types/sqlite3": "^2.2.32", "bluebird": "^3.4.7", + "command-line-args": "^4.0.1", + "command-line-usage": "^4.0.0", "discord.js": "^11.0.0", "js-yaml": "^3.8.1", "marked": "^0.3.6", "matrix-appservice-bridge": "^1.3.5", "mime": "^1.3.4", "npmlog": "^4.0.2", + "sqlite3": "^3.1.8", "tslint": "^4.4.2", "typescript": "^2.1.6" }, diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000000000000000000000000000000000000..7749cbfc2821b2e515c24cc0199e1f9739f65c49 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,393 @@ +import { DiscordBridgeConfig } from "./config"; +import { DiscordClientFactory } from "./clientfactory"; +import { DiscordStore } from "./store"; +import { DiscordDMHandler } from "./dmhandler"; +import { MatrixUser, RemoteUser, Bridge, RemoteRoom, Entry } from "matrix-appservice-bridge"; +import { Util } from "./util"; +import * as Discord from "discord.js"; +import * as log from "npmlog"; +import * as Bluebird from "bluebird"; +import * as mime from "mime"; +import * as marked from "marked"; +import * as path from "path"; + +// Due to messages often arriving before we get a response from the send call, +// messages get delayed from discord. +const MSG_PROCESS_DELAY = 750; +const MATRIX_TO_LINK = "https://matrix.to/#/"; + +class ChannelLookupResult { + public channel: Discord.TextChannel; + public botUser: boolean; +} + +export class DiscordBot { + private config: DiscordBridgeConfig; + private clientFactory: DiscordClientFactory; + private store: DiscordStore; + private bot: Discord.Client; + private discordUser: Discord.ClientUser; + private bridge: Bridge; + private sentMessages: string[]; + constructor(config: DiscordBridgeConfig, store: DiscordStore) { + this.config = config; + this.store = store; + this.sentMessages = []; + this.clientFactory = new DiscordClientFactory(config.auth, store); + } + + public setBridge(bridge: Bridge) { + this.bridge = bridge; + } + + public run (): Promise<null> { + return this.clientFactory.init().then(() => { + return this.clientFactory.getClient(); + }).then((client: any) => { + client.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); + client.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); + client.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); + client.on("channelUpdate", (_, newChannel) => { this.UpdateRooms(<Discord.TextChannel> newChannel); }); + client.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); + client.on("message", (msg) => { Bluebird.delay(MSG_PROCESS_DELAY).then(() => { + this.OnMessage(msg); + }); + }); + this.bot = client; + return null; + }); + } + + public GetGuilds(): Discord.Guild[] { + return this.bot.guilds.array(); + } + + public ThirdpartySearchForChannels(guildId: string, channelName: string): any[] { + if (channelName.startsWith("#")) { + channelName = channelName.substr(1); + } + if (this.bot.guilds.has(guildId) ) { + const guild = this.bot.guilds.get(guildId); + return guild.channels.filter((channel) => { + return channel.name.toLowerCase() === channelName.toLowerCase(); // Implement searching in the future. + }).map((channel) => { + return { + alias: `#_discord_${guild.id}_${channel.id}:${this.config.bridge.domain}`, + protocol: "discord", + fields: { + guild_id: guild.id, + channel_name: channel.name, + channel_id: channel.id, + }, + }; + }); + } else { + log.warn("DiscordBot", "Tried to do a third party lookup for a channel, but the guild did not exist"); + return []; + } + } + + public LookupRoom (server: string, room: string, sender?: string): Promise<ChannelLookupResult> { + let hasSender = sender !== null; + return this.clientFactory.getClient(sender).then((client) => { + const guild = client.guilds.get(server); + if (!guild) { + return Promise.reject(`Guild "${server}" not found`); + } + const channel = guild.channels.get(room); + if (channel) { + const lookupResult = new ChannelLookupResult(); + lookupResult.channel = channel; + lookupResult.botUser = this.bot.user.id === client.user.id; + return lookupResult; + } + return Promise.reject(`Channel "${room}" not found`); + }).catch((err) => { + log.verbose("DiscordBot", "LookupRoom => ", err); + if (hasSender) { + log.verbose("DiscordBot", `Couldn't find guild/channel under user account. Falling back.`); + return this.LookupRoom(server, room, null); + } + throw err; + }); + } + + public ProcessMatrixMsgEvent(event, guildId: string, channelId: string): Promise<any> { + let chan; + let embed; + let botUser; + const mxClient = this.bridge.getClientFactory().getClientAs(); + log.verbose("DiscordBot", `Looking up ${guildId}_${channelId}`); + return this.LookupRoom(guildId, channelId, event.sender).then((result) => { + log.verbose("DiscordBot", `Found channel! Looking up ${event.sender}`); + chan = result.channel; + botUser = result.botUser; + log.verbose("DiscordBot", botUser); + if (result.botUser) { + return mxClient.getProfileInfo(event.sender); + } + return null; + }).then((profile) => { + if (botUser === true) { + if (!profile.displayname) { + profile.displayname = event.sender; + } + if (profile.avatar_url) { + profile.avatar_url = mxClient.mxcUrlToHttp(profile.avatar_url); + } + embed = new Discord.RichEmbed({ + author: { + name: profile.displayname, + icon_url: profile.avatar_url, + url: `https://matrix.to/#/${event.sender}`, + // TODO: Avatar + }, + description: event.content.body, + }); + } + if (["m.image", "m.audio", "m.video", "m.file"].indexOf(event.content.msgtype) !== -1) { + return Util.DownloadFile(mxClient.mxcUrlToHttp(event.content.url)); + } + return Promise.resolve(null); + }).then((attachment) => { + if (attachment !== null) { + let name = this.GetFilenameForMediaEvent(event.content); + return { + file : { + name, + attachment, + }, + }; + } + return {}; + }).then((opts) => { + if (botUser) { + return chan.sendEmbed(embed, opts); + } + return chan.sendMessage(event.content.body, opts); + }).then((msg) => { + this.sentMessages.push(msg.id); + }).catch((err) => { + log.error("DiscordBot", "Couldn't send message. ", err); + }); + } + + public OnUserQuery (userId: string): any { + return false; + } + + private GetFilenameForMediaEvent(content) { + if (content.body) { + if (path.extname(content.body) !== "") { + return content.body; + } + return path.basename(content.body) + "." + mime.extension(content.info.mimetype); + } + return "matrix-media." + mime.extension(content.info.mimetype); + } + + private GetRoomIdsFromChannel(channel: Discord.Channel): Promise<string[]> { + return this.bridge.getRoomStore().getEntriesByRemoteRoomData({ + discord_channel: channel.id, + }).then((rooms) => { + if (rooms.length === 0) { + log.verbose("DiscordBot", `Got message but couldn"t find room chan id:${channel.id} for it.`); + return Promise.reject("Room not found."); + } + return rooms.map((room) => {return room.matrix.getId(); }); + }); + } + + private UpdateRooms(discordChannel: Discord.TextChannel): Promise<null> { + const intent = this.bridge.getIntent(); + const roomStore = this.bridge.getRoomStore(); + return this.GetRoomIdsFromChannel(discordChannel).then((rooms) => { + return roomStore.getEntriesByMatrixIds(rooms).then( (entries) => { + return Object.keys(entries).map((key) => entries[key]); + }); + }).then((entries: any) => { + return Promise.all(entries.map((entry) => { + if (entry.length === 0) { + return Promise.reject("Couldn't update room for channel, no assoicated entry in roomstore."); + } + return this.UpdateRoomEntry(entry[0], discordChannel); + })); + }); + } + + private UpdateRoomEntry(entry: Entry, discordChannel: Discord.TextChannel): Promise<null> { + const intent = this.bridge.getIntent(); + const roomStore = this.bridge.getRoomStore(); + const roomId = entry.matrix.getId(); + return new Promise(() => { + const name = `[Discord] ${discordChannel.guild.name} #${discordChannel.name}`; + if (entry.remote.get("update_name") && entry.remote.get("discord_name") !== name) { + return intent.setRoomName(roomId, name).then(() => { + log.info("DiscordBot", `Updated name for ${roomId}`); + entry.remote.set("discord_name", name); + return roomStore.upsertEntry(entry); + }); + } + }).then(() => { + if ( entry.remote.get("update_topic") && entry.remote.get("discord_topic") !== discordChannel.topic) { + return intent.setRoomTopic(roomId, discordChannel.topic).then(() => { + entry.remote.set("discord_topic", discordChannel.topic); + log.info("DiscordBot", `Updated topic for ${roomId}`); + return roomStore.upsertEntry(entry); + }); + } + }); + } + + private UpdateUser(discordUser: Discord.User) { + let remoteUser: RemoteUser; + const displayName = discordUser.username + "#" + discordUser.discriminator; + const id = `_discord_${discordUser.id}:${this.config.bridge.domain}`; + const intent = this.bridge.getIntent("@" + id); + const userStore = this.bridge.getUserStore(); + + return userStore.getRemoteUser(discordUser.id).then((u) => { + remoteUser = u; + if (remoteUser === null) { + remoteUser = new RemoteUser(discordUser.id); + return userStore.linkUsers( + new MatrixUser(id), + remoteUser, + ); + } + return Promise.resolve(); + }).then(() => { + if (remoteUser.get("displayname") !== displayName) { + return intent.setDisplayName(displayName).then(() => { + remoteUser.set("displayname", displayName); + return userStore.setRemoteUser(remoteUser); + }); + } + return true; + }).then(() => { + if (remoteUser.get("avatarurl") !== discordUser.avatarURL && discordUser.avatarURL !== null) { + return Util.UploadContentFromUrl( + this.bridge, + discordUser.avatarURL, + intent, + discordUser.avatar, + ).then((avatar) => { + intent.setAvatarUrl(avatar.mxc_url).then(() => { + remoteUser.set("avatarurl", discordUser.avatarURL); + return userStore.setRemoteUser(remoteUser); + }); + }); + } + return true; + }); + } + + private UpdatePresence(guildMember: Discord.GuildMember) { + log.info("DiscordBot", `Updating presence for ${guildMember.user.username}#${guildMember.user.discriminator}`); + const intent = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`); + try { + let presence = guildMember.presence.status; + if (presence === "idle" || presence === "dnd") { + presence = "unavailable"; + } + intent.getClient().setPresence({ + presence, + }); + } catch (err) { + log.info("DiscordBot", "Couldn't set presence ", err); + } + // TODO: Set nicknames inside the scope of guild chats. + } + + private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { + return this.GetRoomIdsFromChannel(channel).then((rooms) => { + const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); + return Promise.all(rooms.map((room) => { + return intent.sendTyping(room, isTyping); + })); + }).catch((err) => { + log.verbose("DiscordBot", "Failed to send typing indicator.", err); + }); + } + + private FormatDiscordMessage(msg: Discord.Message): string { + // Replace Users + let content = msg.content; + const userRegex = /<@!?([0-9]*)>/g; + let results = userRegex.exec(content); + while (results !== null) { + const id = results[1]; + const member = msg.guild.members.get(id); + let memberId = `@_discord_${id}:${this.config.bridge.domain}`; + let memberStr = member ? member.user.username : memberId; + content = content.replace(results[0], memberStr); + results = userRegex.exec(content); + } + // Replace channels + const channelRegex = /<#?([0-9]*)>/g; + results = channelRegex.exec(content); + while (results !== null) { + const id = results[1]; + const channel = msg.guild.channels.get(id); + let roomId = `#_discord_${msg.guild.id}_${id}:${this.config.bridge.domain}`; + let channelStr = channel ? "#" + channel.name : "#" + id; + content = content.replace(results[0], `[${channelStr}](${MATRIX_TO_LINK}${roomId})`); + results = channelRegex.exec(content); + } + return content; + } + + private OnMessage(msg: Discord.Message) { + const indexOfMsg = this.sentMessages.indexOf(msg.id); + if (indexOfMsg !== -1) { + log.verbose("DiscordBot", "Got repeated message, ignoring."); + delete this.sentMessages[indexOfMsg]; + return; // Skip *our* messages + } + this.UpdateUser(msg.author).then(() => { + return this.GetRoomIdsFromChannel(msg.channel); + }).then((rooms) => { + const intent = this.bridge.getIntentFromLocalpart(`_discord_${msg.author.id}`); + // Check Attachements + msg.attachments.forEach((attachment) => { + Util.UploadContentFromUrl(this.bridge, attachment.url, intent, attachment.filename).then((content) => { + const fileMime = mime.lookup(attachment.filename); + const msgtype = attachment.height ? "m.image" : "m.file"; + const info = { + mimetype: fileMime, + size: attachment.filesize, + w: null, + h: null, + }; + if (msgtype === "m.image") { + info.w = attachment.width; + info.h = attachment.height; + } + rooms.forEach((room) => { + intent.sendMessage(room, { + body: attachment.filename, + info, + msgtype, + url: content.mxc_url, + }); + }); + }); + }); + if (msg.content !== null && msg.content !== "") { + // Replace mentions. + let content = this.FormatDiscordMessage(msg); + const fBody = marked(content); + rooms.forEach((room) => { + intent.sendMessage(room, { + body: content, + msgtype: "m.text", + formatted_body: fBody, + format: "org.matrix.custom.html", + }); + }); + } + }).catch((err) => { + log.warn("DiscordBot", "Failed to send message into room.", err); + }); + } +} diff --git a/src/clientfactory.ts b/src/clientfactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8e9958c7aeda30ed73254ff1f50107f6eed4100 --- /dev/null +++ b/src/clientfactory.ts @@ -0,0 +1,62 @@ +import { DiscordBridgeConfigAuth } from "./config"; +import { DiscordStore } from "./store"; +import { Client } from "discord.js"; +import * as log from "npmlog"; +import * as Bluebird from "bluebird"; + +export class DiscordClientFactory { + private config: DiscordBridgeConfigAuth; + private store: DiscordStore; + private botClient: any; + private clients: Map<string, any>; + constructor(config: DiscordBridgeConfigAuth, store: DiscordStore) { + this.config = config; + this.clients = new Map(); + this.store = store; + } + + public init(): Promise<null> { + // We just need to make sure we have a bearer token. + // Create a new Bot client. + this.botClient = Bluebird.promisifyAll(new Client({ + fetchAllMembers: true, + sync: true, + messageCacheLifetime: 5, + })); + return this.botClient.login(this.config.botToken).then(() => { + return null; // Strip token from promise. + }).catch((err) => { + log.error("ClientFactory", "Could not login as the bot user. This is bad!"); + }); + } + + public getClient(userId?: string): Promise<any> { + let client; + if (userId) { + if (this.clients.has(userId)) { + log.verbose("ClientFactory", "Returning cached user client."); + return Promise.resolve(this.clients.get(userId)); + } + return this.store.get_user_token(userId).then((token) => { + if (token === null) { + return Promise.resolve(this.botClient); + } + client = Bluebird.promisifyAll(new Client({ + fetchAllMembers: true, + sync: true, + messageCacheLifetime: 5, + })); + log.verbose("ClientFactory", "Got user token. Logging in..."); + return client.login(token).then(() => { + log.verbose("ClientFactory", "Logged in. Storing ", userId); + this.clients.set(userId, client); + return Promise.resolve(client); + }).catch((err) => { + log.warn("ClientFactory", `Could not log ${userId} in.`, err); + }); + }); + // Get from cache + } + return Promise.resolve(this.botClient); + } +} diff --git a/src/config.ts b/src/config.ts index d2c7779424f11a5d3296eedbc0b8858fc3b3387c..d5b266c822e99a5f8e5b372275e642ad3c04e748 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ export class DiscordBridgeConfig { public bridge: DiscordBridgeConfigBridge; public auth: DiscordBridgeConfigAuth; - public guilds: DiscordBridgeConfigGuilds[]; + public logging: DiscordBridgeConfigLogging; } class DiscordBridgeConfigBridge { @@ -11,13 +11,11 @@ class DiscordBridgeConfigBridge { public homeserverUrl: string; } -class DiscordBridgeConfigAuth { +export class DiscordBridgeConfigAuth { public clientID: string; public secret: string; public botToken: string; } - -class DiscordBridgeConfigGuilds { - public id: string; - public aliasName: string; +class DiscordBridgeConfigLogging { + public level: string; } diff --git a/src/dbschema/dbschema.ts b/src/dbschema/dbschema.ts new file mode 100644 index 0000000000000000000000000000000000000000..655b75906fc4c8b2c6cd77aa0c693e43eaa35f49 --- /dev/null +++ b/src/dbschema/dbschema.ts @@ -0,0 +1,5 @@ +import { DiscordStore } from "../store"; +export interface IDbSchema { + description: string; + run(store: DiscordStore): Promise<null>; +} diff --git a/src/dbschema/v1.ts b/src/dbschema/v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca542623e9df7ec6ae46599c4eaf5e4e2be8c3e4 --- /dev/null +++ b/src/dbschema/v1.ts @@ -0,0 +1,19 @@ +import {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../store"; +export class Schema implements IDbSchema { + public description = "Schema, Client Auth Table"; + public run(store: DiscordStore): Promise<null> { + return store.create_table(` + CREATE TABLE schema ( + version INTEGER UNIQUE NOT NULL + );`, "schema").then(() => { + return store.db.runAsync("INSERT INTO schema VALUES (0);"); + }).then(() => { + return store.create_table(` + CREATE TABLE user_tokens ( + userId TEXT UNIQUE NOT NULL, + token TEXT UNIQUE NOT NULL + );`, "user_tokens"); + }) + } +} diff --git a/src/discordas.ts b/src/discordas.ts index 9a2fab766f336c77d7916695e5c4e962ff191f91..c8d6eb6262847b4df5368be32e00b0457a424281 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -3,8 +3,9 @@ import * as log from "npmlog"; import * as yaml from "js-yaml"; import * as fs from "fs"; import { DiscordBridgeConfig } from "./config"; -import { DiscordBot } from "./discordbot"; +import { DiscordBot } from "./bot"; import { MatrixRoomHandler } from "./matrixroomhandler"; +import { DiscordStore } from "./store"; const cli = new Cli({ bridgeConfig: { @@ -34,6 +35,7 @@ function generateRegistration(reg, callback) { } function run (port: number, config: DiscordBridgeConfig) { + log.level = config.logging ? (config.logging.level || "warn") : "warn"; log.info("discordas", "Starting Discord AS"); const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); const registration = AppServiceRegistration.fromObject(yamlConfig); @@ -46,26 +48,21 @@ function run (port: number, config: DiscordBridgeConfig) { token: registration.as_token, url: config.bridge.homeserverUrl, }); - const discordbot = new DiscordBot(config); + const discordstore = new DiscordStore("discord.db"); + const discordbot = new DiscordBot(config, discordstore); const roomhandler = new MatrixRoomHandler(discordbot, config, botUserId); const bridge = new Bridge({ clientFactory, controller: { // onUserQuery: userQuery, - onAliasQuery: (alias, aliasLocalpart) => { - return roomhandler.OnAliasQuery(alias, aliasLocalpart); - }, + onAliasQuery: roomhandler.OnAliasQuery.bind(roomhandler), onEvent: roomhandler.OnEvent.bind(roomhandler), onAliasQueried: roomhandler.OnAliasQueried.bind(roomhandler), thirdPartyLookup: roomhandler.ThirdPartyLookup, - // onLog: function (line, isError) { - // if(isError) { - // if(line.indexOf("M_USER_IN_USE") === -1) {//QUIET! - // log.warn("matrix-appservice-bridge", line); - // } - // } - // } + onLog: (line, isError) => { + log.verbose("matrix-appservice-bridge", line); + }, }, domain: config.bridge.domain, homeserverUrl: config.bridge.homeserverUrl, @@ -73,9 +70,12 @@ function run (port: number, config: DiscordBridgeConfig) { }); roomhandler.setBridge(bridge); discordbot.setBridge(bridge); - + log.info("discordas", "Initing bridge."); log.info("AppServ", "Started listening on port %s at %s", port, new Date().toUTCString() ); bridge.run(port, config); - discordbot.run(); - + log.info("discordas", "Initing store."); + discordstore.init().then(() => { + log.info("discordas", "Initing bot."); + return discordbot.run(); + }); } diff --git a/src/discordbot.ts b/src/discordbot.ts deleted file mode 100644 index 7d63d8c23c8f7603afeadda9865d0a01968a05a5..0000000000000000000000000000000000000000 --- a/src/discordbot.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { DiscordBridgeConfig } from "./config"; -import * as Discord from "discord.js"; -import * as log from "npmlog"; -import { MatrixUser, RemoteUser, Bridge, RemoteRoom } from "matrix-appservice-bridge"; -import { Util } from "./util"; -import * as Bluebird from "bluebird"; -import * as mime from "mime"; -import * as marked from "marked"; - -export class DiscordBot { - private config: DiscordBridgeConfig; - private bot: Discord.Client; - private discordUser: Discord.ClientUser; - private bridge: Bridge; - constructor(config: DiscordBridgeConfig) { - this.config = config; - } - - public setBridge(bridge: Bridge) { - this.bridge = bridge; - } - - public run (): Promise<null> { - this.bot = Bluebird.promisifyAll(new Discord.Client()); - this.bot.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); - this.bot.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); - this.bot.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); - this.bot.on("channelUpdate", (_, newChannel) => { this.UpdateRoom(<Discord.TextChannel> newChannel); }); - this.bot.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); - this.bot.on("message", this.OnMessage.bind(this)); - const promise = (this.bot as any).onAsync("ready"); - this.bot.login(this.config.auth.botToken); - - return promise; - } - - public GetBot (): Discord.Client { - return this.bot; - } - - public GetGuilds(): Discord.Guild[] { - return this.bot.guilds.array(); - } - - public ThirdpartySearchForChannels(guildId: string, channelName: string): any[] { - if (channelName.startsWith("#")) { - channelName = channelName.substr(1); - } - if (this.bot.guilds.has(guildId) ) { - const guild = this.bot.guilds.get(guildId); - return guild.channels.filter((channel) => { - return channel.name.toLowerCase() === channelName.toLowerCase(); // Implement searching in the future. - }).map((channel) => { - return { - alias: `#_discord_${guild.id}_${channel.id}:${this.config.bridge.domain}`, - protocol: "discord", - fields: { - guild_id: guild.id, - channel_name: channel.name, - channel_id: channel.id, - }, - }; - }); - } else { - log.warn("DiscordBot", "Tried to do a third party lookup for a channel, but the guild did not exist"); - return []; - } - } - - public LookupRoom (server: string, room: string): Promise<Discord.TextChannel> { - const guild = this.bot.guilds.find((g) => { - return (g.id === server); - }); - if (!guild) { - return Promise.reject(`Guild "${server}" not found`); - } - - const channel = guild.channels.find((c) => { - return (c.id === room); - }); - - if (!channel) { - return Promise.reject(`Channel "${room}" not found`); - } - return Promise.resolve(channel); - } - - public ProcessMatrixMsgEvent(event, guildId: string, channelId: string): Promise<any> { - let chan; - let embed; - const mxClient = this.bridge.getClientFactory().getClientAs(); - return this.LookupRoom(guildId, channelId).then((channel) => { - chan = channel; - return mxClient.getProfileInfo(event.sender); - }).then((profile) => { - if (!profile.displayname) { - profile.displayname = event.sender; - } - if (profile.avatar_url) { - profile.avatar_url = mxClient.mxcUrlToHttp(profile.avatar_url); - } - embed = new Discord.RichEmbed({ - author: { - name: profile.displayname, - icon_url: profile.avatar_url, - url: `https://matrix.to/#/${event.sender}`, - // TODO: Avatar - }, - description: event.content.body, - }); - if (["m.image", "m.audio", "m.video", "m.file"].indexOf(event.content.msgtype) !== -1) { - return Util.DownloadFile(mxClient.mxcUrlToHttp(event.content.url)); - } - return Promise.resolve(null); - }).then((attachment) => { - if (attachment !== null) { - return { - file : { - name: event.content.body, - attachment, - }, - }; - } - return {}; - }).then((opts) => { - chan.sendEmbed(embed, opts); - }).catch((err) => { - log.error("DiscordBot", "Couldn't send message. ", err); - }); - } - - public OnUserQuery (userId: string): any { - return false; - } - - private GetRoomIdFromChannel(channel: Discord.Channel): Promise<string> { - return this.bridge.getRoomStore().getEntriesByRemoteRoomData({ - discord_channel: channel.id, - }).then((rooms) => { - if (rooms.length === 0) { - log.warn("DiscordBot", `Got message but couldn"t find room chan id:${channel.id} for it.`); - return Promise.reject("Room not found."); - } - return rooms[0].matrix.getId(); - }); - } - - private UpdateRoom(discordChannel: Discord.TextChannel): Promise<null> { - const intent = this.bridge.getIntent(); - const roomStore = this.bridge.getRoomStore(); - let entry: RemoteRoom; - let roomId = null; - return this.GetRoomIdFromChannel(discordChannel).then((r) => { - roomId = r; - return roomStore.getEntriesByMatrixId(roomId); - }).then((entries) => { - if (entries.length === 0) { - return Promise.reject("Couldn't update room for channel, no assoicated entry in roomstore."); - } - entry = entries[0]; - return; - }).then(() => { - const name = `[Discord] ${discordChannel.guild.name} #${discordChannel.name}`; - if (entry.remote.get("discord_name") !== name) { - return intent.setRoomName(roomId, name).then(() => { - entry.remote.set("discord_name", name); - return roomStore.upsertEntry(entry); - }); - } - }).then(() => { - if (entry.remote.get("discord_topic") !== discordChannel.topic) { - return intent.setRoomTopic(roomId, discordChannel.topic).then(() => { - entry.remote.set("discord_topic", discordChannel.topic); - return roomStore.upsertEntry(entry); - }); - } - }); - } - - private UpdateUser(discordUser: Discord.User) { - let remoteUser: RemoteUser; - const displayName = discordUser.username + "#" + discordUser.discriminator; - const id = `_discord_${discordUser.id}:${this.config.bridge.domain}`; - const intent = this.bridge.getIntent("@" + id); - const userStore = this.bridge.getUserStore(); - - return userStore.getRemoteUser(discordUser.id).then((u) => { - remoteUser = u; - if (remoteUser === null) { - remoteUser = new RemoteUser(discordUser.id); - return userStore.linkUsers( - new MatrixUser(id), - remoteUser, - ); - } - return Promise.resolve(); - }).then(() => { - if (remoteUser.get("displayname") !== displayName) { - return intent.setDisplayName(displayName).then(() => { - remoteUser.set("displayname", displayName); - return userStore.setRemoteUser(remoteUser); - }); - } - return true; - }).then(() => { - if (remoteUser.get("avatarurl") !== discordUser.avatarURL && discordUser.avatarURL !== null) { - return Util.UploadContentFromUrl( - this.bridge, - discordUser.avatarURL, - intent, - discordUser.avatar, - ).then((avatar) => { - intent.setAvatarUrl(avatar.mxc_url).then(() => { - remoteUser.set("avatarurl", discordUser.avatarURL); - return userStore.setRemoteUser(remoteUser); - }); - }); - } - return true; - }); - } - - private UpdatePresence(guildMember: Discord.GuildMember) { - log.info("DiscordBot", `Updating presence for ${guildMember.user.username}#${guildMember.user.discriminator}`); - const intent = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`); - try { - let presence = guildMember.presence.status; - if (presence === "idle" || presence === "dnd") { - presence = "unavailable"; - } - intent.getClient().setPresence({ - presence, - }); - } catch (err) { - log.info("DiscordBot", "Couldn't set presence ", err); - } - // TODO: Set nicknames inside the scope of guild chats. - } - - private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { - return this.GetRoomIdFromChannel(channel).then((room) => { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); - intent.sendTyping(room, isTyping); - }); - } - - private OnMessage(msg: Discord.Message) { - if (msg.author.id === this.bot.user.id) { - return; // Skip *our* messages - } - this.UpdateUser(msg.author).then(() => { - return this.GetRoomIdFromChannel(msg.channel); - }).then((room) => { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${msg.author.id}`); - // Check Attachements - msg.attachments.forEach((attachment) => { - Util.UploadContentFromUrl(this.bridge, attachment.url, intent, attachment.filename).then((content) => { - const fileMime = mime.lookup(attachment.filename); - const msgtype = attachment.height ? "m.image" : "m.file"; - const info = { - mimetype: fileMime, - size: attachment.filesize, - w: null, - h: null, - }; - if (msgtype === "m.image") { - info.w = attachment.width; - info.h = attachment.height; - } - intent.sendMessage(room, { - body: attachment.filename, - info, - msgtype, - url: content.mxc_url, - }); - }); - }); - if (msg.content !== null && msg.content !== "") { - // Replace mentions. - const content = msg.content.replace(/<@[0-9]*>/g, (item) => { - const id = item.substr(2, item.length - 3); - const member = msg.guild.members.get(id); - if (member) { - return member.user.username; - } else { - return `@_discord_${id}:${this.config.bridge.domain}`; - } - }); - intent.sendMessage(room, { - body: content, - msgtype: "m.text", - formatted_body: marked(content), - format: "org.matrix.custom.html", - }); - } - }); - } -} diff --git a/src/dmhandler.ts b/src/dmhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f0d4af6c7c683b12a706a4ae4a6502d0c76fbe0 --- /dev/null +++ b/src/dmhandler.ts @@ -0,0 +1,3 @@ +export class DiscordDMHandler { + +} diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index c2545348b59bb0ecc42fb823cb7afb242aa2fdb6..f4d647e3fc1a09262bb58acfeca7b6791bb14e89 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -1,4 +1,4 @@ -import { DiscordBot } from "./discordbot"; +import { DiscordBot } from "./bot"; import { Bridge, RemoteRoom, @@ -8,6 +8,7 @@ import { thirdPartyLocationResult, } from "matrix-appservice-bridge"; import { DiscordBridgeConfig } from "./config"; +import { DiscordClientFactory } from "./clientfactory"; import * as Discord from "discord.js"; import * as log from "npmlog"; @@ -47,19 +48,24 @@ export class MatrixRoomHandler { public OnEvent (request, context) { const event = request.getData(); if (event.type === "m.room.message" && context.rooms.remote) { + log.verbose("MatrixRoomHandler", "Got m.room.message event"); let srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", 2); this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]); + } else { + log.verbose("MatrixRoomHandler", "Got non m.room.message event"); } } public OnAliasQuery (alias: string, aliasLocalpart: string): Promise<any> { + log.info("MatrixRoomHandler", "Got request for #", aliasLocalpart); let srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", 2); if (srvChanPair.length < 2 || srvChanPair[0] === "" || srvChanPair[1] === "") { log.warn("MatrixRoomHandler", `Alias '${aliasLocalpart}' was missing a server and/or a channel`); return; } - return this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]).then((channel) => { - return this.createMatrixRoom(channel, aliasLocalpart); + return this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]).then((result) => { + log.info("MatrixRoomHandler", "Creating #", aliasLocalpart); + return this.createMatrixRoom(result.channel, aliasLocalpart); }).catch((err) => { log.error("MatrixRoomHandler", `Couldn't find discord room '${aliasLocalpart}'.`, err); }); @@ -112,7 +118,6 @@ export class MatrixRoomHandler { public tpGetLocation(protocol: string, fields: any): Promise<thirdPartyLocationResult[]> { log.info("MatrixRoomHandler", "Got location request ", protocol, fields); const chans = this.discord.ThirdpartySearchForChannels(fields.guild_id, fields.channel_name); - console.log(chans); return Promise.resolve(chans); } @@ -140,6 +145,8 @@ export class MatrixRoomHandler { remote.set("discord_type", "text"); remote.set("discord_guild", channel.guild.id); remote.set("discord_channel", channel.id); + remote.set("update_name", true); + remote.set("update_topic", true); const gname = channel.guild.name.replace(" ", "-"); const cname = channel.name.replace(" ", "-"); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c1799f9d0dc5161d87a3436fc1c6dde1e494bab --- /dev/null +++ b/src/store.ts @@ -0,0 +1,136 @@ +import * as SQLite3 from "sqlite3"; +import * as log from "npmlog"; +import * as Bluebird from "bluebird"; +import { IDbSchema } from "./dbschema/dbschema"; + +const CURRENT_SCHEMA = 1; +/** + * Stores data for specific users and data not specific to rooms. + */ +export class DiscordStore { + /** + * @param {string} filepath Location of the SQLite database file. + */ + public db: any; + private version: number; + constructor (filepath) { + this.db = new SQLite3.Database(filepath, (err) => { + if (err) { + log.error("DiscordStore", "Error opening database, %s"); + throw new Error("Couldn't open database. The appservice won't be able to continue."); + } + }); + this.db = Bluebird.promisifyAll(this.db); + this.version = null; + } + + /** + * Checks the database has all the tables needed. + */ + public init () { + log.info("DiscordStore", "Starting DB Init"); + let oldVersion; + let version; + return this.getSchemaVersion().then( (v) => { + oldVersion = v; + version = v; + let promises = []; + while (version < CURRENT_SCHEMA) { + version++; + const schemaClass = require(`./dbschema/v${version}.js`).Schema; + const schema = (new schemaClass() as IDbSchema); + log.info("DiscordStore", `Updating database to v${version}, ${schema.description}`); + promises.push(schema.run(this).then(() => { + log.info("DiscordStore", "Updated database v%s", version); + })); + this.version = version; + } + return Promise.all(promises); + }).then( () => { + return this.setSchemaVersion(oldVersion, version).then( () => { + log.info("DiscordStore", "Updated database to the latest schema"); + }); + }).catch( (err) => { + log.error("DiscordStore", "Couldn't update database to the latest version! Bailing"); + throw err; + }); + } + + public create_table (statement, tablename) { + return this.db.runAsync(statement).then(() => { + log.info("DiscordStore", "Created table ", tablename); + }).catch((err) => { + throw new Error(`Error creating '${tablename}': ${err}`); + }); + } + + public close () { + this.db.close(); + } + + public set_user_token(userId: string, token: string) { + log.silly("SQL", "set_user_token => %s", userId); + return this.db.runAsync( + `REPLACE INTO user_tokens (userId,token) VALUES ($id,$token);` + , { + $id: userId, + $token: token, + }).catch( (err) => { + log.error("TwitDB", "Error storing user token %s", err); + throw err; + }); + } + + public get_user_token(userId: string): Promise<string> { + log.silly("SQL", "get_user_token => %s", userId); + return this.db.getAsync( + ` + SELECT token + FROM user_tokens + WHERE user_tokens.userId = $id; + ` + , { + $id: userId, + }).then( (row) => { + return row !== undefined ? row.token : null; + }).catch( (err) => { + log.error("TwitDB", "Error getting user token %s", err.Error); + throw err; + }); + } + + public get_users_tokens(): Promise<any> { + log.silly("SQL", "get_users_tokens"); + return this.db.allAsync( + ` + SELECT * + FROM user_tokens + `, + ).then( (rows) => { + return rows; + }).catch( (err) => { + log.error("TwitDB", "Error getting user token %s", err.Error); + throw err; + }); + } + + private getSchemaVersion ( ) { + log.silly("DiscordStore", "_get_schema_version"); + return this.db.getAsync(`SELECT version FROM schema`).then((row) => { + return row === undefined ? 0 : row.version; + }).catch( () => { + return 0; + }); + } + + private setSchemaVersion (oldVer: number, ver: number) { + log.silly("DiscordStore", "_set_schema_version => %s", ver); + return this.db.getAsync( + ` + UPDATE schema + SET version = $ver + WHERE version = $old_ver + `, {$ver: ver, $old_ver: oldVer}, + ); + } +} diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index 40914bc440369da90e512ddfc5724de8a08bb781..46345db761962c43b64e094f134607584ec7883e 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -121,39 +121,15 @@ describe("DiscordBot", () => { ); discordBot.run(); it("should reject a missing guild.", () => { - return assert.isRejected(discordBot.LookupRoom("MyMissingGuild", "achannel")); - }); - - it("should resolve a guild.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "achannel")); - }); - - it("should resolve a guild with an id.", () => { - return assert.isFulfilled(discordBot.LookupRoom("123", "achannel")); - }); - - it("should resolve a guild with spaces.", () => { - return assert.isFulfilled(discordBot.LookupRoom("My-Spaces-Guild", "achannel")); - }); - - it("should resolve a guild with dashes.", () => { - return assert.isFulfilled(discordBot.LookupRoom("My-Dash-Guild", "achannel")); + return assert.isRejected(discordBot.LookupRoom("541", "321")); }); it("should reject a missing channel.", () => { - return assert.isRejected(discordBot.LookupRoom("MyGuild", "amissingchannel")); - }); - - it("should resolve a channel with spaces.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "a channel")); + return assert.isRejected(discordBot.LookupRoom("123", "666")); }); - it("should resolve a channel with dashes.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "a-channel")); - }); - - it("should resolve a channel with an id.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "321")); + it("should resolve a guild and channel id.", () => { + return assert.isFulfilled(discordBot.LookupRoom("123", "321")); }); }); // describe("ProcessMatrixMsgEvent()", () => { @@ -171,8 +147,8 @@ describe("DiscordBot", () => { describe("OnTyping()", () => { const discordBot = new modDiscordBot.DiscordBot( config, - mockBridge, ); + discordBot.setBridge(mockBridge); discordBot.run(); it("should reject an unknown room.", () => { return assert.isRejected(discordBot.OnTyping( {id: "512"}, {id: "12345"}, true)); diff --git a/tools/addbot.js b/tools/addbot.js deleted file mode 100644 index 86d55daca8199114718094ba074c18c8d06e1463..0000000000000000000000000000000000000000 --- a/tools/addbot.js +++ /dev/null @@ -1,18 +0,0 @@ -const yaml = require("js-yaml"); -const fs = require("fs"); -const flags = require("../node_modules/discord.js/src/util/Constants.js").PermissionFlags; -const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); -if (yamlConfig === null) { - console.error("You have an error in your discord config."); -} -const client_id = yamlConfig.auth.clientID; -const perms = flags.READ_MESSAGES | - flags.SEND_MESSAGES | - flags.CHANGE_NICKNAME | - flags.CONNECT | - flags.SPEAK | - flags.EMBED_LINKS | - flags.ATTACH_FILES | - flags.READ_MESSAGE_HISTORY; - -console.log(`Go to https://discordapp.com/api/oauth2/authorize?client_id=${client_id}&scope=bot&permissions=${perms} to invite the bot into a guild.`); diff --git a/tools/addbot.ts b/tools/addbot.ts new file mode 100644 index 0000000000000000000000000000000000000000..f54d66beb66e6b5f707e1c6b0131257ce517ab05 --- /dev/null +++ b/tools/addbot.ts @@ -0,0 +1,25 @@ + /* tslint:disable:no-bitwise no-console no-var-requires */ +/** + * Generates a URL you can use to authorize a bot with a guild. + */ +import * as yaml from "js-yaml"; +import * as fs from "fs"; + +const flags = require("../../node_modules/discord.js/src/util/Constants.js").PermissionFlags; +const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); +if (yamlConfig === null) { + console.error("You have an error in your discord config."); +} +const clientId = yamlConfig.auth.clientID; + +const perms = flags.READ_MESSAGES | + flags.SEND_MESSAGES | + flags.CHANGE_NICKNAME | + flags.CONNECT | + flags.SPEAK | + flags.EMBED_LINKS | + flags.ATTACH_FILES | + flags.READ_MESSAGE_HISTORY; + +const url = `https://discordapp.com/api/oauth2/authorize?client_id=${clientId}&scope=bot&permissions=${perms}`; +console.log(`Go to ${url} to invite the bot into a guild.`); diff --git a/tools/adminme.ts b/tools/adminme.ts new file mode 100644 index 0000000000000000000000000000000000000000..60a62112881a24e6d170dd0503a403f4d37249b0 --- /dev/null +++ b/tools/adminme.ts @@ -0,0 +1,114 @@ +/* tslint:disable:no-console */ +/** + * Allows you to become an admin for a room the bot is in control of. + */ + +import { Cli, Bridge, AppServiceRegistration, ClientFactory } from "matrix-appservice-bridge"; +import * as log from "npmlog"; +import * as yaml from "js-yaml"; +import * as fs from "fs"; +import * as args from "command-line-args"; +import * as usage from "command-line-usage"; +import { DiscordBridgeConfig } from "../src/config"; + +const optionDefinitions = [ + { + name: "help", + alias: "h", + type: Boolean, + description: "Display this usage guide."}, + { + name: "config", + alias: "c", + type: String, + defaultValue: "config.yaml", + description: "The AS config file.", + typeLabel: "<config.yaml>" }, + { + name: "roomid", + alias: "r", + type: String, + description: "The roomid to modify"}, + { + name: "userid", + alias: "u", + type: String, + description: "The userid to give powers"}, + { + name: "power", + alias: "p", + type: Number, + defaultValue: 100, + description: "The power to set", + typeLabel: "<0-100>" }, +]; + +const options = args(optionDefinitions); + +if (options.help) { + /* tslint:disable:no-console */ + console.log(usage([ + { + header: "Admin Me", + content: "A tool to give a user a power level in a bot user controlled room."}, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +if (!options.roomid) { + console.error("Missing roomid parameter. Check -h"); + process.exit(1); +} + +if (!options.userid) { + console.error("Missing userid parameter. Check -h"); + process.exit(1); +} + +const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); +const registration = AppServiceRegistration.fromObject(yamlConfig); +const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig; + +if (registration === null) { + throw new Error("Failed to parse registration file"); +} + +const clientFactory = new ClientFactory({ + appServiceUserId: "@" + registration.sender_localpart + ":" + config.bridge.domain, + token: registration.as_token, + url: config.bridge.homeserverUrl, +}); + +const client = clientFactory.getClientAs(); +client.startClient(); +client.on("sync", (state, prevState, data) => { + switch (state) { + case "ERROR": + console.error("Sync failed.", data); + break; + case "SYNCING": + console.log("Syncing."); + break; + case "PREPARED": + const room = client.getRoom(options.roomid); + if (room === null) { + console.error("Room not found."); + process.exit(1); + } + const levels = room.getLiveTimeline().getState("f").getStateEvents("m.room.power_levels")[0]; + client.setPowerLevel(options.roomid, options.userid, options.power, levels).then(() => { + console.log("Power levels set"); + process.exit(0); + }).catch((err) => { + console.error("Could not apply power levels: ", err); + process.exit(1); + }) + break; + default: + break; + } +}); diff --git a/tsconfig.json b/tsconfig.json index c5651a0591da08bfac5cd8395e06276590a4c28c..a888421480d0a4e9d844699924faa847afce9fee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "compileOnSave": true, "include": [ "src/**/*", - "test/**/*" + "test/**/*", + "tools/**/*" ] }