diff --git a/config/config.sample.yaml b/config/config.sample.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8fb83dcb1bc2d4fae32d7f47b17691b3fa783c5c --- /dev/null +++ b/config/config.sample.yaml @@ -0,0 +1,7 @@ +bridge: + domain: localhost + homeserverUrl: http://localhost:8008 +auth: + clientID: 12345 # Get from discord + secret: blah + botToken: foobar diff --git a/config/config.schema.yaml b/config/config.schema.yaml new file mode 100644 index 0000000000000000000000000000000000000000..798771b14f4ebc5eb57f82021970728d0055f2eb --- /dev/null +++ b/config/config.schema.yaml @@ -0,0 +1,22 @@ +"$schema": "http://json-schema.org/draft-04/schema#" +type: "object" +required: ["bridge", "auth"] +properties: + bridge: + type: "object" + required: ["domain", "homeserverUrl"] + properties: + domain: + type: "string" + homeserverUrl: + type: "string" + auth: + type: "object" + required: ["botToken"] + properties: + clientID: + type: "string" + secret: + type: "string" + botToken: + type: "string" diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..c9af63d94d57dc5cccc655bd6a3471f5968131e8 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "matrix-appservice-discord", + "version": "0.0.1", + "description": "A bridge between Matrix and Discord", + "main": "discordas.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "tslint --project ./tsconfig.json", + "coverage": "istanbul --include-all-sources cover -x /src/discordas.js _mocha -- test/ -R spec", + "build": "tsc", + "start": "tsc && node ./build/discordas.js -p 9005 -c config.yaml", + "getbotlink": "node ./tools/addbot.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Half-Shot/matrix-appservice-discord.git" + }, + "keywords": [ + "matrix", + "discord", + "bridge", + "application-service", + "as" + ], + "author": "Half-Shot", + "license": "MIT", + "bugs": { + "url": "https://github.com/Half-Shot/matrix-appservice-discord/issues" + }, + "homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme", + "dependencies": { + "@types/node": "^7.0.5", + "bluebird": "^3.4.7", + "discord.js": "^11.0.0", + "js-yaml": "^3.8.1", + "npmlog": "^4.0.2", + "tslint": "^4.4.2", + "typescript": "^2.1.6" + }, + "devDependencies": { + "chai": "^3.5.0", + "chai-as-promised": "^6.0.0", + "eslint": "^3.8.1", + "istanbul": "^0.4.5", + "mocha": "^3.1.2" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2c7779424f11a5d3296eedbc0b8858fc3b3387c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,23 @@ +/** Type annotations for config/config.schema.yaml */ + +export class DiscordBridgeConfig { + public bridge: DiscordBridgeConfigBridge; + public auth: DiscordBridgeConfigAuth; + public guilds: DiscordBridgeConfigGuilds[]; +} + +class DiscordBridgeConfigBridge { + public domain: string; + public homeserverUrl: string; +} + +class DiscordBridgeConfigAuth { + public clientID: string; + public secret: string; + public botToken: string; +} + +class DiscordBridgeConfigGuilds { + public id: string; + public aliasName: string; +} diff --git a/src/discordas.ts b/src/discordas.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bfbf460324196d5fe548ec656b6cc4b84170be1 --- /dev/null +++ b/src/discordas.ts @@ -0,0 +1,79 @@ +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 { DiscordBridgeConfig } from "./config"; +import { DiscordBot } from "./discordbot"; +import { MatrixRoomHandler } from "./matrixroomhandler"; + +const cli = new Cli({ + bridgeConfig: { + affectsRegistration: true, + schema: "./config/config.schema.yaml", + }, + registrationPath: "discord-registration.yaml", + generateRegistration, + run, +}); + +try { + cli.run(); +} catch (err) { + console.error("Init", "Failed to start bridge."); // eslint-disable-line no-console + console.error("Init", err); // eslint-disable-line no-console +} + +function generateRegistration(reg, callback) { + reg.setId(AppServiceRegistration.generateToken()); + reg.setHomeserverToken(AppServiceRegistration.generateToken()); + reg.setAppServiceToken(AppServiceRegistration.generateToken()); + reg.setSenderLocalpart("_discord_bot"); + reg.addRegexPattern("users", "@_discord_.*", true); + reg.addRegexPattern("aliases", "#_discord_.*", true); + callback(reg); +} + +function run (port: number, config: DiscordBridgeConfig) { + log.info("discordas", "Starting Discord AS"); + const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); + const registration = AppServiceRegistration.fromObject(yamlConfig); + 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 bridge = new Bridge({ + clientFactory, + controller: { + // onUserQuery: userQuery, + onAliasQuery: (alias, aliasLocalpart) => { + return roomhandler.OnAliasQuery(alias, aliasLocalpart); + }, + onEvent: (request, context) => { roomhandler.OnEvent(request, context); }, + onAliasQueried: (alias, roomId) => { return roomhandler.OnAliasQueried(alias, roomId); }, + // onLog: function (line, isError) { + // if(isError) { + // if(line.indexOf("M_USER_IN_USE") === -1) {//QUIET! + // log.warn("matrix-appservice-bridge", line); + // } + // } + // } + }, + domain: config.bridge.domain, + homeserverUrl: config.bridge.homeserverUrl, + registration, + }); + + const discordbot = new DiscordBot(config, bridge); + const roomhandler = new MatrixRoomHandler(bridge, discordbot, config); + + log.info("AppServ", "Started listening on port %s at %s", port, new Date().toUTCString() ); + bridge.run(port, config); + discordbot.run(); + +} diff --git a/src/discordbot.ts b/src/discordbot.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd41e2213c3e19f7fb6369f0e187839393a77512 --- /dev/null +++ b/src/discordbot.ts @@ -0,0 +1,111 @@ +import { DiscordBridgeConfig } from "./config"; +import * as Discord from "discord.js"; +import * as log from "npmlog"; + +export class DiscordBot { + private config: DiscordBridgeConfig; + private bot: Discord.Client; + private bridge; + constructor(config: DiscordBridgeConfig, bridge) { + this.config = config; + this.bridge = bridge; + } + + public run () { + this.bot = new Discord.Client(); + + this.bot.on("ready", () => { + log.info("DiscordBot", "I am ready!"); + }); + + this.bot.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); + this.bot.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); + // create an event listener for messages + this.bot.on("message", (msg) => { + this.GetRoomIdFromChannel(msg.channel).then((room) => { + const intent = this.bridge.getIntentFromLocalpart(`_discord_${msg.author.id}`); + intent.sendText(room, msg.content); + }); + }); + + this.bot.login(this.config.auth.botToken); + } + + 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 OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { + this.GetRoomIdFromChannel(channel).then((room) => { + const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); + intent.sendTyping(room, isTyping); + }); + + } + + public GetBot (): Discord.Client { + return this.bot; + } + + public LookupRoom (server: string, room: string): Promise<Discord.TextChannel> { + const guild = this.bot.guilds.find((g) => { + return (g.id === server || g.name.replace(" ", "-") === server); + }); + if (guild === null) { + return Promise.reject("Guild not found"); + } + + const channel = guild.channels.find((c) => { + return ((c.id === room || c.name.replace(" ", "-") === room ) && c.type === "text"); + }); + if (channel === null) { + return Promise.reject("Channel not found"); + } + return Promise.resolve(channel); + + } + + public ProcessMatrixMsgEvent(event, guild_id: string, channel_id: string): Promise<any> { + if (event.content.msgtype !== "m.text") { + return Promise.reject("The AS doesn't support non m.text messages"); + } + let chan; + const mxClient = this.bridge.getClientFactory().getClientAs(); + this.LookupRoom(guild_id, channel_id).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); + } + const 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, + }); + log.info("DiscordBot", "Outgoing Message ", embed) + chan.sendEmbed(embed); + }).catch((err) => { + log.error("DiscordBot", "Couldn't send message. ", err); + }); + } + + public OnUserQuery (userId: string): any { + return false; + } +} diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4d3f4f5ec1453d0ece87c6fd8741fde062e5b84 --- /dev/null +++ b/src/matrixroomhandler.ts @@ -0,0 +1,115 @@ +import { DiscordBot } from "./discordbot"; +import { Bridge, RemoteRoom } from "matrix-appservice-bridge"; +import { DiscordBridgeConfig } from "./config"; + +import * as Discord from "discord.js"; +import * as log from "npmlog"; + +export class MatrixRoomHandler { + private config: DiscordBridgeConfig; + private bridge: Bridge; + private discord: DiscordBot; + private alias_list: any; + constructor (bridge: Bridge, discord: DiscordBot, config: DiscordBridgeConfig) { + this.bridge = bridge; + this.discord = discord; + this.config = config; + this.alias_list = {}; + } + + public OnAliasQueried (alias: string, roomId: string) { + const aliasLocalpart = alias.substr(1, alias.length - `:${this.config.bridge.domain}`.length - 1); + log.info("MatrixRoomHandler", `Room created ${aliasLocalpart} => ${roomId}`); + if (this.alias_list[aliasLocalpart] == null) { + log.warn("MatrixRoomHandler", "Room was created but we couldn't assign additonal aliases"); + return; + } + const mxClient = this.bridge.getClientFactory().getClientAs(); + this.alias_list[aliasLocalpart].forEach((item) => { + if (item === "#" + aliasLocalpart) { + return; + } + mxClient.createAlias(item, roomId).catch( (err) => { + log.warn("MatrixRoomHandler", `Failed to create alias '${aliasLocalpart} for ${roomId}'`, err); + }); + }); + delete this.alias_list[aliasLocalpart]; + } + + public OnEvent (request, context) { + console.log(context); + const event = request.getData(); + if (event.type === "m.room.message" && context.rooms.remote) { + let srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", 2); + this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]); + } + } + + public OnAliasQuery (alias: string, aliasLocalpart: string): Promise<any> { + 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); + }).catch((err) => { + log.error("MatrixRoomHandler", `Couldn't find discord room '${aliasLocalpart}'.`, err); + }); + } + + private createMatrixRoom (channel: Discord.TextChannel, alias: string) { + const botID = this.bridge.getBot().getUserId(); + // const roomOwner = "@_discord_" + user.id_str + ":" + this._bridge.opts.domain; + const users = {}; + users[botID] = 100; + // users[roomOwner] = 75; + // var powers = util.roomPowers(users); + const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}`); + remote.set("discord_type", "text"); + remote.set("discord_guild", channel.guild.id); + remote.set("discord_channel", channel.id); + + const gname = channel.guild.name.replace(" ", "-"); + const cname = channel.name.replace(" ", "-"); + + this.alias_list[alias] = [ + `#_discord_${channel.guild.id}_${channel.id}:${this.config.bridge.domain}`, + `#_discord_${channel.guild.id}_${cname}:${this.config.bridge.domain}`, + `#_discord_${gname}_${channel.id}:${this.config.bridge.domain}`, + `#_discord_${gname}_${cname}:${this.config.bridge.domain}`, + ]; + + const creationOpts = { + visibility: "public", + room_alias_name: alias, + name: `[Discord] ${channel.guild.name}#${channel.name}`, + topic: channel.topic ? channel.topic : "", + // invite: [roomOwner], + initial_state: [ + // powers, + { + type: "m.room.join_rules", + content: { + join_rule: "public", + }, + state_key: "", + } + // }, { + // type: "org.matrix.twitter.data", + // content: user, + // state_key: "" + // }, { + // type: "m.room.avatar", + // state_key: "", + // content: { + // url: avatar + // } + ], + }; + return { + creationOpts, + remote, + }; + } +} diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000000000000000000000000000000000000..a93481d22320c95def1406620b32c228b27f0268 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,3 @@ +--reporter list +--ui bdd +--recursive diff --git a/tools/addbot.js b/tools/addbot.js new file mode 100644 index 0000000000000000000000000000000000000000..86d55daca8199114718094ba074c18c8d06e1463 --- /dev/null +++ b/tools/addbot.js @@ -0,0 +1,18 @@ +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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..b51be7ecfed92da985567e893e71390904ef1347 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "Node", + "target": "ES6", + "noImplicitAny": false, + "sourceMap": false, + "outDir": "./build" + }, + "compileOnSave": true, + "include": [ + "src/**/*" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000000000000000000000000000000000000..c49a430912b2ce1a84844f77da03771404e0ad6c --- /dev/null +++ b/tslint.json @@ -0,0 +1,9 @@ +{ + "extends": "tslint:recommended", + "rules": { + "ordered-imports": "off", + "no-trailing-whitespace": "off", + "max-classes-per-file": "off", + "object-literal-sort-keys": "off" + } +}