diff options
36 files changed, 1924 insertions, 282 deletions
@@ -1,93 +1,61 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +*.sqlite3 -# C extensions -*.so +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg +# Runtime data +pids +*.pid +*.seed +*.pid.lock -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Coverage directory used by tools like istanbul +coverage -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ +# nyc test coverage +.nyc_output -# Translations -*.mo -*.pot +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt -# Django stuff: -*.log -!/logs -!run*.log -local_settings.py +# Bower dependency directory (https://bower.io/) +bower_components -# Flask stuff: -instance/ -.webassets-cache +# node-waf configuration +.lock-wscript -# Scrapy stuff: -.scrapy +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release -# Sphinx documentation -docs/_build/ +# Dependency directories +node_modules/ +jspm_packages/ -# PyBuilder -target/ +# Typescript v1 declaration files +typings/ -# Jupyter Notebook -.ipynb_checkpoints +# Optional npm cache directory +.npm -# pyenv -.python-version +# Optional eslint cache +.eslintcache -# celery beat schedule file -celerybeat-schedule +# Optional REPL history +.node_repl_history -# dotenv -.env +# Output of 'npm pack' +*.tgz -# virtualenv -.venv/ -venv/ -ENV/ +# Yarn Integrity file +.yarn-integrity -# Spyder project settings -.spyderproject +# dotenv environment variables file +.env -# Rope project settings -.ropeproject diff --git a/.gitmodules b/.gitmodules index 52948d0..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "joblib"] - path = joblib - url = https://github.com/vainglorygame/joblib diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..326d1cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:7.7-alpine + +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app + +COPY . /usr/src/app +RUN npm install && npm cache clean + +CMD ["node", "bot.js"] @@ -0,0 +1,294 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const request = require("request-promise"), + Promise = require("bluebird"), + WebSocket = require("ws"), + webstomp = require("webstomp-client"), + cacheManager = require("cache-manager"), + sleep = require("sleep-promise"), + Channel = require("async-csp").Channel; +const api = module.exports; + +let cache = cacheManager.caching({ + store: "memory", + ttl: 10 // s +}); + +const UPDATE_TIMEOUT = parseInt(process.env.UPDATE_TIMEOUT) || 30; // s + +const API_FE_URL = process.env.API_FE_URL || "http://vainsocial.bot/bot/api", + API_MAP_URL = process.env.API_MAP_URL || "http://vainsocial.dev/masters/", + API_WS_URL = process.env.API_WS_URL || "ws://vainsocial.dev/ws", + API_BE_URL = process.env.API_BE_URL || "http://vainsocial.dev/bridge"; + +const notif = webstomp.over(new WebSocket(API_WS_URL, + { perMessageDeflate: false })); + +(function connect() { + notif.connect("web", "web", + () => console.log("connected to queue"), + (err) => connect() + ); +})(); + +module.exports.getMap = async (url) => { + return await request({ + uri: API_MAP_URL + url, + json: true, + forever: true + }); +} + +module.exports.getFE = module.exports.get = async (url, params={}, ttl=60, cachekey=undefined) => { + if (cachekey == undefined) cachekey = url + JSON.stringify(params); + return await cache.wrap(cachekey, async () => await request({ + uri: API_FE_URL + url, + qs: params, + json: true, + forever: true + }), { ttl: ttl }); +} + +// send a POST and optionally bust cache +module.exports.postFE = module.exports.post = async (url, params={}, cachekey=undefined) => { + if (cachekey) cache.del(cachekey); + return await request.post(API_FE_URL + url, { + form: params, + json: true, + forever: true + }); +} + +// send a DELETE and optionally bust cache +module.exports.deleteFE = module.exports.delete = async (url, params={}, cachekey=undefined) => { + if (cachekey) cache.del(cachekey); + return await request.delete(API_FE_URL + url, { + form: params, + json: true, + forever: true + }); +} + +// send a PUT and optionally bust cache +module.exports.putFE = module.exports.put = async (url, params={}, cachekey=undefined) => { + if (cachekey) cache.del(cachekey); + return await request.put(API_FE_URL + url, { + form: params, + json: true, + forever: true + }); +} + +module.exports.postBE = module.exports.backend = async (url) => { + return await request.post({ + uri: API_BE_URL + url, + json: true, + forever: true + }); +} + +function subscribe(topic, channel) { + return notif.subscribe("/topic/" + topic, (msg) => { + channel.put(msg.body); + msg.ack(); + }, { ack: "client" }); +} + +// return id<->name mappings +module.exports.getMappings = async () => { + return await cache.wrap("mappings", async () => { + let mapping = new Map(); + await Promise.all([ + Promise.map( + ["gamemode"], async (table) => { + mapping[table] = new Map(); + (await api.getMap(table)).map( + (map) => mapping[table][map["id"]] = map["name"]) + } + ), + // name <-> API name + Promise.map( + ["hero"], async (table) => { + mapping[table] = new Map(); + (await api.getMap(table)).map( + (map) => mapping[table][map["api_name"]] = map["name"]) + } + ) + ]); + return mapping; + }, { ttl: 60 * 30 }); +} + +module.exports.mapGameMode = async (id) => + (await api.getMappings())["gamemode"][id]; + +module.exports.mapActor = async (api_name) => + (await api.getMappings())["hero"][api_name]; + +// return a set of IGN of supporters +module.exports.getGamers = async () => + (await api.getFE("/gamer", {}, 60 * 30)).map((gamer) => gamer.name); + +// be an async iterator +// next() returns promises that are awaited until there is an update +module.exports.subscribeUpdates = (name, timeout=UPDATE_TIMEOUT) => { + const channel = new Channel(), + subscription = subscribe("player." + name, channel); + let subscribed = true; + + function stop() { + if (!subscribed) return; + subscribed = false; + channel.close(); + subscription.unsubscribe(); + clearTimeout(timer); + } + + // stop updates after timeout + const timer = setTimeout(() => stop(), timeout*1000); + + return { next: async () => { + let msg; + do msg = await channel.take(); + while([Channel.DONE, "search_fail", "search_success", + "stats_update", "matches_update", "matches_none"] + .indexOf(msg) == -1); + // bust caches + if (msg == "stats_update") + cache.del("player+" + name); + if (msg == "matches_update") { + cache.del("matches+" + name); + cache.del("player+" + name); + } + if (msg == "matches_none") stop(); // no new data + if (msg == "search_fail") { + stop(); + throw { error: { + err: "No player found for the provided IGN." + } }; + } + if (msg == Channel.DONE) { + subscribed = false; + subscription.unsubscribe(); + return undefined; + } + return msg; + }, stop: stop }; +} + +// search an unknown player +module.exports.searchPlayer = (name) => + api.postBE("/player/" + name + "/search"); + +// update a known player +module.exports.updatePlayer = (name) => + api.postBE("/player/" + name + "/update"); + +// return player +module.exports.getPlayer = async (name) => { + return await api.getFE("/player/" + name, {}, 60, "player+" + name); +} + +// search or update a player +module.exports.upsearchPlayer = async (name) => { + try { + await api.getPlayer(name); + await api.updatePlayer(name); + } catch (err) { + await api.searchPlayer(name); + } +} + +// block until update is completely done +module.exports.upsearchPlayerSync = async (name) => { + const waiter = await api.subscribeUpdates(name); + await api.upsearchPlayer(name); + while ([undefined, "matches_update", "matches_none"] + .indexOf(await waiter.next()) == -1); + waiter.stop(); +} + +// return matches +module.exports.getMatches = async (name) => { + const data = await api.getFE("/player/" + name + "/matches/1.1.1.1", {}, + 60 * 60, "matches+" + name); + return data.data; +} + +// return single match +module.exports.getMatch = async (id) => { + return await api.getFE("/match/" + id, {}, 60 * 60); +} + +// return a guild +module.exports.getGuild = async (token) => { + return await api.getFE("/guild", { user_token: token }, 60, "guild+" + token); +} + +// return a guild +module.exports.getGuildMembersByGuildName = async (name) => { + // TODO caching + return await api.getFE("/guild/" + name + "/members", { }, 0); +} +// TODO! cache guilds by guild id, not by user token + +// add user to guild +module.exports.addToGuild = async (token, member) => { + const membership = await api.postFE("/guild/members", { + user_token: token, + member_name: member + }, "guild+" + token); + return membership; +} + +// kick from guild +module.exports.removeFromGuild = async (token, member) => { + const membership = await api.deleteFE("/guild/members/" + member, { + user_token: token + }, "guild+" + token); + return membership; +} + +// change a role +module.exports.changeRole = async (token, member, role) => { + const membership = await api.putFE("/guild/members/updateRole", { + user_token: token, + member_name: member, + new_role: role + }, "guild+" + token); + return membership; +} + +// recalc fame, block until timeout or points update +module.exports.calculateGuild = async (id, token) => { + const channel = new Channel(), + subscription = subscribe("global", channel); + await api.backend("/team/" + id + "/crunch"); + + // stop updates after timeout + setTimeout(() => channel.close(), UPDATE_TIMEOUT*1000); + + let msg; + do msg = await channel.take(); + while([Channel.DONE, "points_update"].indexOf(msg) == -1); + + if (msg == "points_update") cache.del("guild+" + token); + subscription.unsubscribe(); +} + +// store Discord ID <-> IGN +module.exports.setUser = async (token, name) => { + cache.del("user+" + token); + await api.post("/user", { + name: name, + user_token: token + }); +} + +// retrieve Discord ID -> IGN +module.exports.getUser = async (token) => { + const user = await api.getFE("/user", { user_token: token }, 60, "user+" + token); + return user.name; +} @@ -0,0 +1,103 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const PREVIEW = process.env.PREVIEW != "false"; + +const sqlite = require("sqlite"), + path = require("path"), + winston = require("winston"), + loggly = require("winston-loggly-bulk"), + Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + client = new Commando.Client({ + owners: ["227440521704898561", "208974925199966208"], + // shutterfly, stormcaller + commandPrefix: (PREVIEW? "?" : "!"), + invite: "https://discord.gg/txTchJY", + unknownCommandResponse: false + }), + util = require("./util"); + +const DISCORD_TOKEN = process.env.DISCORD_TOKEN, + LOGGLY_TOKEN = process.env.LOGGLY_TOKEN; + +const logger = new (winston.Logger)({ + transports: [ + new (winston.transports.Console)({ + timestamp: true, + colorize: true + }) + ] +}); + +if (LOGGLY_TOKEN) + logger.add(winston.transports.Loggly, { + inputToken: LOGGLY_TOKEN, + subdomain: "kvahuja", + tags: ["frontend", "discordbot"], + json: true + }); + +client + .on("error", logger.error) + .on("warn", logger.warn) + .on("debug", logger.info) + .on("ready", () => { + logger.info(`Client ready; logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`); + util.rotateGameStatus(client); + }) + .on('disconnect', () => { logger.warn('Disconnected!'); }) + .on('reconnecting', () => { logger.warn('Reconnecting...'); }) + .on('commandError', (cmd, err) => { + if(err instanceof Commando.FriendlyError) return; + logger.error(`Error in command ${cmd.groupID}:${cmd.memberName}`, err); + }) + .on('commandBlocked', (msg, reason) => { + logger.info(oneLine` + Command ${msg.command ? `${msg.command.groupID}:${msg.command.memberName}` : ''} + blocked; ${reason} + `); + }) + .on('commandPrefixChange', (guild, prefix) => { + logger.info(oneLine` + Prefix ${prefix === '' ? 'removed' : `changed to ${prefix || 'the default'}`} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }) + .on('commandStatusChange', (guild, command, enabled) => { + logger.info(oneLine` + Command ${command.groupID}:${command.memberName} + ${enabled ? 'enabled' : 'disabled'} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }) + .on('groupStatusChange', (guild, group, enabled) => { + logger.info(oneLine` + Group ${group.id} + ${enabled ? 'enabled' : 'disabled'} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }) + + // response reaction interface + .on("messageReactionAdd", util.onNewReaction); + +client.setProvider( + sqlite.open(path.join(__dirname, "settings.sqlite3")).then( + db => new Commando.SQLiteProvider(db))); + +client.registry + .registerGroup('vainsocial', 'VainSocial') + .registerGroup('vainsocial-guild', 'VainSocial Guild management') + .registerDefaultTypes() + .registerDefaultGroups() + .registerDefaultCommands({ eval_: false, commandState: false }) + .registerCommandsIn(path.join(__dirname, 'commands')); + + +client.login(DISCORD_TOKEN); + +process.on("unhandledRejection", err => { + logger.error("Uncaught Promise Error", err); +}); diff --git a/commands/vainsocial/about.js b/commands/vainsocial/about.js new file mode 100644 index 0000000..e7941e3 --- /dev/null +++ b/commands/vainsocial/about.js @@ -0,0 +1,37 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"); + +const PREVIEW = process.env.PREVIEW != "false", + ROOTURL = (PREVIEW? "https://preview.vainsocial.com/":"https://vainsocial.com/"); + +module.exports = class ShowAboutCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "about", + group: "vainsocial", + memberName: "about", + description: "Shows invite links and developer contact details." + }); + } + async run(msg) { + util.trackAction(msg, "about"); + await msg.embed(util.vainsocialEmbed("About VainSocial", "", "about") + .setDescription( + `Built by the VainSocial development team using the MadGlory API. + Currently running on ${msg.client.guilds.size} servers.`) + .addField("Website", + ROOTURL + util.track("about"), true) + .addField("Bot invite link", + "https://discordapp.com/oauth2/authorize?&client_id=287297889024213003&scope=bot&permissions=52288", true) + .addField("Developer Discord invite", + "https://discord.gg/txTchJY", true) + .addField("Twitter", + "https://twitter.com/vainsocial", true) + ); + } +}; diff --git a/commands/vainsocial/guild_add.js b/commands/vainsocial/guild_add.js new file mode 100644 index 0000000..bf8f8f6 --- /dev/null +++ b/commands/vainsocial/guild_add.js @@ -0,0 +1,52 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("../../api"), + util = require("../../util"), + GuildAddView = require("../../views/guild_progress"); + +module.exports = class AddGuildMemberCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildadd", + aliases: ["vguild-add", "vgadd", "vga"], + group: "vainsocial-guild", + memberName: "vainsocial-guildadd", + description: "Register a member to your Guild.", + details: oneLine` +Register IGNs to your Guild. +`, + examples: ["vgadd StormCallerSr", "vgadd StormCallerSr shutterfly"], + argsType: "multiple" + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-add"); + let playersStatus = {}; + const playersWaiters = args.map((name) => api.subscribeUpdates(name)), + guildAddView = new GuildAddView(msg, playersStatus); + // create waiter dict & data dict + await Promise.each(playersWaiters, async (waiter, idx) => { + let success = false; + try { + playersStatus[args[idx]] = "Loading…"; + await guildAddView.respond(); + await api.upsearchPlayerSync(args[idx]); + success = true; + } catch (err) { + console.error(err); + playersStatus[args[idx]] = err.error.err; + success = false; + } + if (success) { + await api.addToGuild(msg.author.id, args[idx]); + playersStatus[args[idx]] = "Loaded."; + } + }); + await guildAddView.respond("Your Guild members were added."); + } +}; diff --git a/commands/vainsocial/guild_create.js b/commands/vainsocial/guild_create.js new file mode 100644 index 0000000..a8caa6a --- /dev/null +++ b/commands/vainsocial/guild_create.js @@ -0,0 +1,70 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"), + api = require("../../api"), + GuildCreateView = require("../../views/guild_create"); + +module.exports = class RegisterGuildCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildcreate", + aliases: ["vguild-create", "vgcreate"], + group: "vainsocial-guild", + memberName: "vainsocial-guildcreate", + description: "Register a new Guild.", + details: oneLine` +Create a Guild with your VainSocial profile as leader. +`, + examples: ["vguild-create MyAmazingGuild MAG eu"], + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your Guild's name.", + type: "string", + min: 3 + }, { + key: "tag", + label: "tag", + prompt: "Please specify your Guild's tag.", + type: "string", + min: 2, + max: 4 + }, { + // TODO use enum + key: "region", + label: "region", + prompt: "Please specify your Guild's region.", + type: "string", + min: 2, + max: 10 + } ] + }); + } + // register a VainSocial Guild to a Discord account + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-create"); + const guildCreateView = new GuildCreateView(msg, msg.author.id); + if (msg.guild.id != 283790513998659585) + return await guildCreateView.error(oneLine` +Guilds creation is currently running in closed beta. +If you want to participate, join our development Discord: https://discord.gg/txTchJY +`); + try { + await api.post("/guild", { + shard_id: args.region, + name: args.name, + identifier: args.tag, + user_token: msg.author.id + }); + } catch (err) { + console.error(err); + return await guildCreateView.error(err.error.err); + } + await guildCreateView.respond(); + } +}; diff --git a/commands/vainsocial/guild_member.js b/commands/vainsocial/guild_member.js new file mode 100644 index 0000000..e72fff1 --- /dev/null +++ b/commands/vainsocial/guild_member.js @@ -0,0 +1,74 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("../../api"), + util = require("../../util"), + GuildMemberView = require("../../views/guild_member"); + +module.exports = class ViewGuildMemberCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildmember", + aliases: ["vguild-member", "vgm"], + group: "vainsocial-guild", + memberName: "vainsocial-guildmember", + description: "View a Guild member in detail.", + examples: ["vgm StormCallerSr"], + args: [ { + key: "name", + label: "name", + prompt: "Please specify a Guild member's name.", + type: "string", + min: 2, + default: "?" + }, { + key: "guild", + label: "guild", + prompt: "Please specify a Guild's name.", + type: "string", + min: 2, + default: "?" + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-member"); + const guildMemberView = new GuildMemberView(msg); + // get IGN or default + let ign; + try { + ign = await util.ignForUser(args.name, msg.author.id); + } catch (err) { + return await guildMemberView.error(strings.unknown(msg)); + } + // get guild: name, server default, self + let guildName = args.guild, guild; + if (guildName == "?") { + guildName = msg.guild.settings.get("default-guild-name"); + } + + try { + let members; + // TODO. + if (guildName == undefined) members = (await api.getGuild(msg.author.id)).members; + else members = await api.getGuildMembersByGuildName(guildName); + console.log(guildName); + if (members.length == 0) + throw { error: { err: "Could not find that Guild." } }; + + const member = members.filter((m) => m.player.name == args.name)[0]; + if (member == undefined) + throw { error: { err: "Player is not in the Guild." } }; + const player = await api.getPlayer(member.player.name); + const matches = await api.getMatches(player.name); + await guildMemberView.respond(member, player, matches); + } catch (err) { + console.error(err); + return await guildMemberView.error(err.error.err); + } + } +}; diff --git a/commands/vainsocial/guild_pin.js b/commands/vainsocial/guild_pin.js new file mode 100644 index 0000000..986666e --- /dev/null +++ b/commands/vainsocial/guild_pin.js @@ -0,0 +1,46 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"), + api = require("../../api"), + SimpleView = require("../../views/simple"); + +module.exports = class PinGuildCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildpin", + aliases: ["vguild-pin", "vgpin"], + group: "vainsocial-guild", + memberName: "vainsocial-guildpin", + description: "Share your Guild on a server.", + examples: ["vgview"], + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your Guild's name.", + type: "string", + min: 2, + default: "?" + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-pin"); + const pinView = new SimpleView(msg); + if (!msg.member.hasPermission('ADMINISTRATOR')) + return await pinView.error("You need to be server administrator to pin a GUild."); + let guild; + try { + guild = await api.getGuild(msg.author.id); + } catch (err) { + console.log(err); + return await guildOverviewView.error(err.error.err); + } + msg.guild.settings.set("default-guild-name", guild.name); + await pinView.respond(`Successfully pinned the Guild \`${guild.name}\` to your server.`); + } +}; diff --git a/commands/vainsocial/guild_rm.js b/commands/vainsocial/guild_rm.js new file mode 100644 index 0000000..e74aea4 --- /dev/null +++ b/commands/vainsocial/guild_rm.js @@ -0,0 +1,48 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("../../api"), + util = require("../../util"), + GuildRmView = require("../../views/guild_progress"); + +module.exports = class AddGuildMemberCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildrm", + aliases: ["vguild-rm", "vgrm"], + group: "vainsocial-guild", + memberName: "vainsocial-guildrm", + description: "Remove a member from your Guild.", + details: oneLine` +Remove an IGN from your Guild. +`, + examples: ["vgrm StormCallerSr", "vgrm StormCallerSr shutterfly"], + argsType: "multiple" + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-rm"); + let playersStatus = {}; + const guildRmView = new GuildRmView(msg, playersStatus); + try { + await Promise.each(args, async (name) => { + try { + playersStatus[name] = "Removing…"; + await api.removeFromGuild(msg.author.id, name); + playersStatus[name] = "Removed."; + await guildRmView.respond(); + } catch (err) { + playersStatus[name] = err.error.err; + } + }); + } catch (err) { + console.error(err); + return await guildRmView.error(err.error.err); + } + await guildRmView.respond("Your Guild members were removed."); + } +}; diff --git a/commands/vainsocial/guild_role.js b/commands/vainsocial/guild_role.js new file mode 100644 index 0000000..50b88e4 --- /dev/null +++ b/commands/vainsocial/guild_role.js @@ -0,0 +1,46 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("../../api"), + util = require("../../util"), + SimpleView = require("../../views/simple"); + +module.exports = class RoleGuildMemberCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildrole", + aliases: ["vguild-role", "vgrole", "vgr"], + group: "vainsocial-guild", + memberName: "vainsocial-guildrole", + description: "Change a Guild member's role.", + examples: ["vgrole StormCallerSr Officer", "vgrole shutterfly Leader"], + args: [ { + key: "name", + label: "name", + prompt: "Please specify a Guild member's name.", + type: "string", + min: 2 + }, { + key: "role", + label: "role", + prompt: "Please specify the member's new role.", + type: "string" + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-role"); + const simpleView = new SimpleView(msg); + try { + const member = await api.changeRole(msg.author.id, args.name, args.role); + await simpleView.respond(`Successfully changed ${args.name}'s role to ${args.role}.`); + } catch (err) { + console.log(err); + return await simpleView.error(err.error.err); + } + } +}; diff --git a/commands/vainsocial/guild_update.js b/commands/vainsocial/guild_update.js new file mode 100644 index 0000000..a61647f --- /dev/null +++ b/commands/vainsocial/guild_update.js @@ -0,0 +1,60 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("../../api"), + util = require("../../util"), + GuildMembersProgressView = require("../../views/guild_progress"); + +module.exports = class UpdateGuildCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildupdate", + aliases: ["vguild-update", "vgupdate"], + group: "vainsocial-guild", + memberName: "vainsocial-guildupdate", + description: "Update your Guild members' data.", + details: oneLine` +Update the match history for all your Guild members. +`, + examples: ["vgupdate"] + }); + } + // internal / premium: immediately call backend player refresh + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-update"); + // obj of ign: player + let playersStatus = {}; + // collect an array of IGNs + let names, guild; + const guildUpdateView = new GuildMembersProgressView(msg, playersStatus); + try { + guild = await api.getGuild(msg.author.id); + names = guild.members.map((m) => m.player.name); + } catch (err) { + console.log(err); + return await guildUpdateView.error(err.error.err); + } + // update all the IGNs + const playersWaiters = names.map((name) => api.subscribeUpdates(name)); + // create waiter dict & data dict + await Promise.each(playersWaiters, async (waiter, idx) => { + playersStatus[names[idx]] = "Loading…"; + await guildUpdateView.respond(); + await api.upsearchPlayerSync(names[idx]); + playersStatus[names[idx]] = "Loaded."; + await guildUpdateView.respond(); + }); + await guildUpdateView.respond("Your Guild's fame is being updated…"); + try { + await api.calculateGuild(guild.id, msg.author.id); + } catch (err) { + console.log(err); + await guildUpdateView.error(err.error.err); + } + await guildUpdateView.respond("Your Guild was updated."); + } +}; diff --git a/commands/vainsocial/guild_view.js b/commands/vainsocial/guild_view.js new file mode 100644 index 0000000..28ddad3 --- /dev/null +++ b/commands/vainsocial/guild_view.js @@ -0,0 +1,46 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"), + api = require("../../api"), + GuildOverviewView = require("../../views/guild"); + +module.exports = class ViewGuildCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-guildview", + aliases: ["vguild-view", "vgview", "vgv"], + group: "vainsocial-guild", + memberName: "vainsocial-guildview", + description: "View members of a Guild.", + details: oneLine` +Show a summary of your Guild. +`, + examples: ["vgview"], + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your Guild's name.", + type: "string", + min: 2, + default: "?" + } ] + }); + } + // show Guild details + async run(msg, args) { + util.trackAction(msg, "vainsocial-guild-view"); + const guildOverviewView = new GuildOverviewView(msg); + try { + const guild = await api.getGuild(msg.author.id); + await guildOverviewView.respond(guild); + } catch (err) { + console.log(err); + return await guildOverviewView.error(err.error.err); + } + } +}; diff --git a/commands/vainsocial/match.js b/commands/vainsocial/match.js new file mode 100644 index 0000000..3acde84 --- /dev/null +++ b/commands/vainsocial/match.js @@ -0,0 +1,68 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"), + api = require("../../api"), + strings = require("../../strings"), + MatchView = require("../../views/match"); + +module.exports = class ShowMatchCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-match", + aliases: ["vm"], + group: "vainsocial", + memberName: "vainsocial-match", + description: "Shows a user's match in detail.", + examples: ["vm shutterfly 1"], + argsType: "multiple", + argsCount: 2, + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string", + default: "?", + min: 3, + max: 16 + }, { + key: "number", + label: "number", + prompt: "Please specify how far you want to go back in history. Use 1 or leave out for the latest match.", + type: "integer", + default: 1, + min: 1, + max: 10 + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-match", args.name); + let ign; + try { + ign = await util.ignForUser(args.name, msg.author.id); + } catch (err) { + return await new MatchView(msg, undefined).error(strings.unknown(msg)); + } + + const matchView = new MatchView(msg); + try { + const participations = await api.getMatches(ign); + if (args.number > participations.length) + return await msg.say(strings.tooFewMatches(ign)); + // wait for BE update + const waiter = api.subscribeUpdates(ign); + await api.upsearchPlayer(ign); + + do await matchView.respond(participations[args.number-1].match_api_id); + while (await waiter.next() != undefined); + } catch (err) { + console.error(err); + return await matchView.error(err.error.err); + } + } +}; diff --git a/commands/vainsocial/matches.js b/commands/vainsocial/matches.js new file mode 100644 index 0000000..7499725 --- /dev/null +++ b/commands/vainsocial/matches.js @@ -0,0 +1,56 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + util = require("../../util"), + api = require("../../api"), + strings = require("../../strings"), + MatchesView = require("../../views/matches"); + +module.exports = class ShowMatchesCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-history", + aliases: ["vh"], + group: "vainsocial", + memberName: "vainsocial-matches", + description: "Shows a user's match history.", + examples: ["vh shutterfly"], + argsType: "single", + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string", + default: "?", + min: 3, + max: 16 + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-matches", args.name); + let ign; + try { + ign = await util.ignForUser(args.name, msg.author.id); + } catch (err) { + return await strings.unknown(msg); + } + + const matchesView = new MatchesView(msg, ign); + // wait for BE update + try { + const waiter = api.subscribeUpdates(ign); + await api.upsearchPlayer(ign); + + do await matchesView.respond(); + while (await waiter.next() != undefined); + } catch (err) { + console.log(err); + return await matchesView.error(err.error.err); + } + } +}; diff --git a/commands/vainsocial/me.js b/commands/vainsocial/me.js new file mode 100644 index 0000000..8679a65 --- /dev/null +++ b/commands/vainsocial/me.js @@ -0,0 +1,47 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + api = require("../../api"), + util = require("../../util"), + RegisterView = require("../../views/register"); + +module.exports = class RegisterUserCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-me", + aliases: ["vme", "vgme"], + group: "vainsocial", + memberName: "vainsocial-me", + description: "Register a users's in game name.", + details: oneLine` +Store your in game name for quicker access to other commands and for Guild management. + `, + examples: ["vme shutterfly"], + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string", + min: 3, + max: 16 + } ] + }); + } + // register a Discord account at VainSocial + async run(msg, args) { + util.trackAction(msg, "vainsocial-me", args.name); + const registerView = new RegisterView(msg, args.name); + try { + await api.upsearchPlayerSync(args.name); + await api.setUser(msg.author.id, args.name); + } catch (err) { + console.log(err); + return await registerView.error(err.error.err); + } + await registerView.respond(); + } +}; diff --git a/commands/vainsocial/user.js b/commands/vainsocial/user.js new file mode 100644 index 0000000..e251ad7 --- /dev/null +++ b/commands/vainsocial/user.js @@ -0,0 +1,59 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + emoji = require("discord-emoji"), + util = require("../../util"), + strings = require("../../strings"), + api = require("../../api"), + PlayerView = require("../../views/player"); + +module.exports = class ShowUserCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-user", + aliases: ["v", "vu"], + group: "vainsocial", + memberName: "vainsocial-user", + description: "Shows a player\'s profile.", + details: oneLine` +Display VainSocial lifetime statistics from Vainglory + `, + examples: ["vu shutterfly"], + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string", + default: "?", + min: 3, + max: 16 + } ] + }); + } + async run(msg, args) { + util.trackAction(msg, "vainsocial-user", args.name); + let ign; + try { + ign = await util.ignForUser(args.name, msg.author.id); + } catch (err) { + return await new PlayerView(msg, args.name).error(strings.unknown(msg)); + } + + const playerView = new PlayerView(msg, ign); + try { + // wait for BE update + const waiter = api.subscribeUpdates(ign); + await api.upsearchPlayer(ign); + + do await playerView.respond(); + while (await waiter.next() != undefined); + } catch (err) { + console.log(err); + return await playerView.error(err.error.err); + } + } +}; diff --git a/joblib b/joblib deleted file mode 160000 -Subproject 062f24ea27e7340deb5c46aa495a2467f2e42e7 diff --git a/main.py b/main.py deleted file mode 100644 index f46fa73..0000000 --- a/main.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import os -import asyncio -import asyncpg -import discord -from discord.ext import commands -import joblib.joblib - -source_db = { - "host": os.environ.get("POSTGRESQL_SOURCE_HOST") or "localhost", - "port": os.environ.get("POSTGRESQL_SOURCE_PORT") or 5433, - "user": os.environ.get("POSTGRESQL_SOURCE_USER") or "vainraw", - "password": os.environ.get("POSTGRESQL_SOURCE_PASSWORD") or "vainraw", - "database": os.environ.get("POSTGRESQL_SOURCE_DB") or "vainsocial-raw" -} - -dest_db = { - "host": os.environ.get("POSTGRESQL_DEST_HOST") or "localhost", - "port": os.environ.get("POSTGRESQL_DEST_PORT") or 5432, - "user": os.environ.get("POSTGRESQL_DEST_USER") or "vainweb", - "password": os.environ.get("POSTGRESQL_DEST_PASSWORD") or "vainweb", - "database": os.environ.get("POSTGRESQL_DEST_DB") or "vainsocial-web" -} - - -bot = commands.Bot( - command_prefix="!", - description="Vainsocial Vainglory stats bot") - -queue = None -pool = None - -async def connect(queuedb, db): - logging.warning("connecting to database") - - global queue - queue = joblib.joblib.JobQueue() - await queue.connect(**queuedb) - await queue.setup() - - global pool - pool = await asyncpg.create_pool( - min_size=1, **db) - - -@bot.event -async def on_ready(): - logging.warning("Logged in as %s (" + - "https://discordapp.com/oauth2/authorize?&" + - "client_id=%s&scope=bot)", - bot.user.name, bot.user.id) - await connect(source_db, dest_db) - await bot.change_presence( - game=discord.Game( - name="alpha.vainsocial.com")) - - -@bot.event -async def on_command_error(error, ctx): - logging.error(error) - if ctx.invoked_subcommand: - pages = bot.formatter.format_help_for( - ctx, ctx.invoked_subcommand) - for page in pages: - await bot.send_message( - ctx.message.channel, page) - else: - pages = bot.formatter.format_help_for( - ctx, ctx.command) - for page in pages: - await bot.send_message( - ctx.message.channel, page) - - -@bot.command() -async def vainsocial(name: str, region: str): - """Retrieves a player's stats.""" - region = region.lower() - if region not in ["na", "eu", "sg", "ea", "sa"]: - await bot.say("That region is not supported.") - return - - query = """ - SELECT - player.name, - player.shard_id, - match.game_mode, - roster.match_api_id, - participant.hero, participant.winner, - participant.kills, participant.deaths, participant.assists, participant.farm, - player.skill_tier, player.played, player.wins, - player.last_match_created_date::text - FROM match, roster, participant, player where - match.api_id=roster.match_api_id AND - roster.api_id=participant.roster_api_id AND - participant.player_api_id=player.api_id AND - player.name=$1 AND player.last_match_created_date::text<>$2 - ORDER BY match.created_at DESC - LIMIT 1 - """ - def emb(dct): - data = dict(dct) - - modes = { - "blitz_pvp_ranked": "Blitz", - "casual_aral": "Battle Royale", - "private": "private casual", - "private_party_draft_match": "private draft", - "private_party_blitz_match": "private Blitz", - "private_party_aral_match": "private Battle Royale" - } - data["mode"] = modes.get(data["game_mode"]) or data["game_mode"] - heroes = { - "Sayoc": "Taka", - "Hero009": "Krul", - "Hero010": "Skaarf", - "Hero016": "Rona" - } - data["hero"] = data["hero"].replace("*", "") - data["hero"] = heroes.get(data["hero"]) or data["hero"] - data["result"] = "won" if data["winner"] else "lost" - - emb = discord.Embed( - title="%(name)s" % data, - url="https://alpha.vainsocial.com/players/%(shard_id)s/%(name)s" % data - ) - emb.set_author(name="Vainsocial", - url="https://alpha.vainsocial.com") - emb.add_field(name="Profile", - value=("%(wins)i wins / %(played)i games\n" + - "https://alpha.vainsocial.com/players/%(shard_id)s/%(name)s") % data) - emb.add_field(name="Last match", - value=("%(result)s %(mode)s as %(hero)s %(kills)i/%(deaths)i/%(assists)i\n" + - "https://alpha.vainsocial.com/matches/%(match_api_id)s") % data) - - emb.set_footer(text="Vainsocial - Vainglory social stats service") - emb.set_thumbnail(url="https://alpha.vainsocial.com/images/game/skill_tiers/%(skill_tier)s.png" % data) - return emb - - async with pool.acquire() as con: - await bot.type() - - data = await con.fetchrow(query, name, 'NULL') - if data is None: - in_cache = False # new user - lmcd = "2017-02-01T00:00:00Z" - msgid = await bot.say( - "%s: please wait…" % name) - else: - in_cache = True # returning user - lmcd = data["last_match_created_date"] - msgid = await bot.say(embed=emb(data)) - - logging.info("'%s' cached: %s", name, in_cache) - - payload = { - "region": region, - "params": { - "filter[createdAt-start]": lmcd, - "filter[playerNames]": name - } - } - jobid = (await queue.request(jobtype="grab", - payload=[payload], - priority=[1]))[0] - - while True: - # wait for grab job to finish - status = await queue.status(jobid) - if status == 'finished': - break - if status == 'failed': - logging.warning("'%s': not found", name) - if not in_cache: - await bot.edit_message(msgid, - "Could not find you.") - return - asyncio.sleep(0.5) - - while True: - # wait for processor to update the player - data = await con.fetchrow(query, name, lmcd) - if data is not None: - break - asyncio.sleep(0.5) - - await bot.edit_message(msgid, embed=emb(data)) - - -logging.basicConfig(level=logging.INFO) -bot.run(os.environ["VAINSOCIAL_DISCORDTOKEN"]) diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc94066 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "discordbot", + "version": "2.0.0", + "description": "", + "main": "bot.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "schneefux", + "license": "UNLICENSED", + "dependencies": { + "async-csp": "^0.5.0", + "bluebird": "^3.5.0", + "bufferutil": "^2.0.0", + "cache-manager": "^2.4.0", + "discord-emoji": "^1.1.1", + "discord.js": "git+https://github.com/hydrabolt/discord.js.git", + "discord.js-commando": "git+https://github.com/Gawdl3y/discord.js-commando.git", + "erlpack": "github:hammerandchisel/erlpack", + "request": "^2.81.0", + "request-promise": "^4.2.0", + "sleep-promise": "^2.0.0", + "sqlite": "^2.5.0", + "universal-analytics": "^0.4.13", + "webstomp": "0.0.10", + "webstomp-client": "^1.0.6", + "winston": "^2.3.1", + "winston-loggly-bulk": "^1.4.2", + "ws": "^2.2.3" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7c3bc62..0000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -aiohttp==1.0.5 -appdirs==1.4.2 -async-timeout==1.1.0 -asyncpg==0.9.0 -chardet==2.3.0 -discord.py==0.16.7 -multidict==2.1.4 -packaging==16.8 -pyparsing==2.1.10 -six==1.10.0 -websockets==3.2 diff --git a/responses.js b/responses.js new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/responses.js diff --git a/strings.js b/strings.js new file mode 100644 index 0000000..546dfc0 --- /dev/null +++ b/strings.js @@ -0,0 +1,48 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const emoji = require("discord-emoji"), + oneLine = require("common-tags").oneLine, + Promise = require("bluebird"), + api = require("./api"), + util = require("./util"); +const strings = module.exports; + +const MATCH_HISTORY_LEN = parseInt(process.env.MATCH_HISTORY_LEN) || 3, + PREVIEW = process.env.PREVIEW != "false", + ROOTURL = (PREVIEW? "https://preview.vainsocial.com/":"https://vainsocial.com/"); + +// tell the user that they need to store their name +module.exports.unknown = (msg) => + `You're unknown to our service. Try ${util.usg(msg, "vme")}.`; + +module.exports.notFound = (ign) => + `Could not find ${ign}.`; + +module.exports.noMatches = (ign) => + `Could not find any casual/ranked matches for ${ign}.`; + +module.exports.tooFewMatches = (ign) => + `Could not find that many casual/ranked matches for ${ign}.`; + +module.exports.loading = (ign) => + `Loading data for ${ign}…`; + +module.exports.emojiCount = [ emoji.symbols.one, emoji.symbols.two, emoji.symbols.three, + emoji.symbols.four, emoji.symbols.five, emoji.symbols.six, + emoji.symbols.seven, emoji.symbols.eight, emoji.symbols.nine, + emoji.symbols.ten +]; + +module.exports.profile = "Profile"; +module.exports.lastMatch = "Last Match"; + +// based on impact score float, return an Emoji +module.exports.emojifyScore = (score) => { + if (score > 0.7) return emoji.people.heart_eyes; + if (score > 0.6) return emoji.people.blush; + if (score > 0.5) return emoji.people.yum; + if (score > 0.3) return emoji.people.relieved; + return emoji.people.upside_down; +}; @@ -0,0 +1,149 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Discord = require("discord.js"), + ua = require("universal-analytics"), + api = require("./api"), + strings = require("./strings"), + Promise = require("bluebird"), + Channel = require("async-csp").Channel; +const util = module.exports; + +const GOOGLEANALYTICS_ID = process.env.GOOGLEANALYTICS_ID, + REACTION_TIMEOUT = parseInt(process.env.REACTION_TIMEOUT) || 60, // s + IGN_ROTATE_TIMEOUT = parseInt(process.env.IGN_ROTATE_TIMEOUT) || 300, + PREVIEW = process.env.PREVIEW != "false", + ROOTURL = (PREVIEW? "https://preview.vainsocial.com/":"https://vainsocial.com/"); + +const reactionsPipe = new Channel(); + +// embed template +module.exports.vainsocialEmbed = (title, link, command) => { + return new Discord.RichEmbed() + .setTitle(title) + .setColor("#55ADD3") + .setURL(ROOTURL + encodeURIComponent(link) + util.track(command)) + .setAuthor("VainSocial" + (PREVIEW? " preview":""), ROOTURL + "images/brands/logo-blue.png", + ROOTURL + util.track(command)) + .setFooter("VainSocial" + (PREVIEW? " preview":""), ROOTURL + "images/brands/logo-blue.png") +}; + +// "playing…" +module.exports.rotateGameStatus = (client) => { + (async function rotate() { + const gamers = await api.getGamers(), + idx = Math.floor(Math.random() * (gamers.length - 1)) + 1; + if (PREVIEW) await client.user.setGame( + `?v ${gamers[idx]} | preview.vainsocial.com`); + else await client.user.setGame( + `!v ${gamers[idx]} | vainsocial.com`); + setTimeout(rotate, IGN_ROTATE_TIMEOUT * 1000); + })(); +} + +// analytics url +module.exports.track = (command) => { + return "?utm_source=discordbot&utm_medium=discord&utm_campaign=" + command; +}; + +// direct analytics +module.exports.trackAction = (msg, action, ign="") => { + if (GOOGLEANALYTICS_ID == undefined) return; + const user = ua(GOOGLEANALYTICS_ID, msg.author.id, + { strictCidFormat: false }); + user.pageview({ + documentPath: action, + documentTitle: ign, + campaignSource: (msg.guild? msg.guild.id : 0), + campaignMedium: (msg.guild? msg.guild.name : "private") + }).send(); +}; + +// return the shortest version of the usage help +// just '?vm' +module.exports.usg = (msg, cmd) => { + return msg.anyUsage(cmd, undefined, null); +}; + +// reaction -> pipe ->>> consumers +// handle new reaction event +module.exports.onNewReaction = (reaction) => { + if (reaction.users.array().length <= 1) return; // was me TODO + reactionsPipe.put(reaction); +}; + +// create an iterator that returns promises to await new reactions +// the Promise result is the reaction name +module.exports.awaitReactions = (message, emoji, timeout=REACTION_TIMEOUT) => { + const pipeOut = new Channel(); + let reactions = []; + // stop listening after timeout + setTimeout(() => { + pipeOut.close(); + Promise.map(reactions, (r) => r.remove()); + }, timeout*1000); + + // async in background + Promise.each(emoji, async (em) => + reactions.push(await message.react(em))); + + let reaction; + reactionsPipe.pipe(pipeOut); + return { next: async function() { + do { + reaction = await pipeOut.take(); + if (reaction == Channel.DONE) return undefined; + } while (reaction.message.id != message.id || + emoji.indexOf(reaction.emoji.name) == -1); + return reaction.emoji.name; + } } +}; + +// opts: object with key=emoji, value=func +module.exports.reactionButtons = async (msg, opts) => { + if (msg.reactions.size > 0) return; // already added + const reactionWaiter = util.awaitReactions(msg, Object.keys(opts)); + while (true) { + const rmoji = await reactionWaiter.next(); + if (rmoji == undefined) break; // timeout + if (opts[rmoji]) await opts[rmoji](); + } +} + +// respond or say text or embed +module.exports.respond = async (msg, data, response) => { + if (response == undefined) { + if (typeof data === "string") response = await msg.say(data); + else response = await msg.embed(data); + } else { + if (typeof data === "string") { + if (data != response.content) response = await response.edit(data); + } else { + if (response.embeds.length == 0 || + new Date(response.embeds[0].createdTimestamp).getTime() + != data.timestamp.getTime()) { + // TODO how2 edit embed properly?! + response = await msg.editResponse(response, { + type: "plain", + content: "", + options: { embed: data } + }); + } + } + } + return response; +} + +// array split generator +module.exports.paginate = function* chunks(arr, pagesize) { + for (let c=0, len=arr.length; c<len; c+=pagesize) + yield arr.slice(c, c+pagesize); +} + +// return ign, or associated ign, or undefined +module.exports.ignForUser = async (name, user_token) => { + // "?" is not accepted as user input, but the default for empty args + if (name != "?") return name; + return await api.getUser(user_token); +} diff --git a/views/guild.js b/views/guild.js new file mode 100644 index 0000000..15863b0 --- /dev/null +++ b/views/guild.js @@ -0,0 +1,35 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + GuildMemberView = require("./guild_member"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + Promise = require("bluebird"), + oneLine = require("common-tags").oneLine; + +const GuildOverviewView = module.exports; + +// match detail view +module.exports = class extends View { + text(members) { + // TODO remove when API supports server side sort + return members.sort((m1, m2) => m1.fame < m2.fame).map((m) => + new GuildMemberView().text(m)).join("\n"); + } + + async embed(guild) { + const embed = util.vainsocialEmbed(`${guild.name} - ${guild.shard_id}`, + "", "vainsocial-guild-view") + .setDescription(await this.text(guild.members)); + return embed; + } + + async respond(guild, extra="") { + this.response = await util.respond(this.msg, + await this.embed(guild), this.response); + return this.response; + } +} diff --git a/views/guild_create.js b/views/guild_create.js new file mode 100644 index 0000000..38720e1 --- /dev/null +++ b/views/guild_create.js @@ -0,0 +1,49 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const emoji = require("discord-emoji"), + View = require("./view"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const GuildCreateView = module.exports; + +// match detail view +module.exports = class extends View { + constructor(msg, user_token) { + super(msg); + this.user_token = user_token; + } + + async text() { + return `Guild created. You can now use ${util.usg(this.msg, "vgadd ign1 ign2 ignN")} to add members to your Guild.`; + } + + async help() { + return `*${emoji.symbols.information_source} or ${util.usg(this.msg, "vgview")} to view your Guild*` + } + + async buttons() { + let reactions = {}; + reactions[emoji.symbols.information_source] = async () => { + util.trackAction(this.msg, "reaction-guildview"); + await new GuildOverviewView(this.msg).respond(this.user_token); + }; + return reactions; + } + + // TODO move to super class + async respond() { + this.response = await util.respond(this.msg, + await this.text() + "\n" + await this.help(), this.response); + if (!this.hasButtons) { + await util.reactionButtons(this.response, + await this.buttons()); + this.hasButtons = true; + } + return this.response; + }; +} diff --git a/views/guild_member.js b/views/guild_member.js new file mode 100644 index 0000000..d75ffe4 --- /dev/null +++ b/views/guild_member.js @@ -0,0 +1,35 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + PlayerView = require("./player"), + MatchesView = require("./matches"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const GuildMemberView = module.exports; + +// combined fame + profile + last match +module.exports = class extends View { + text(member) { + return `${member.player.name} | ${member.status} | ${member.fame} VS Fame`; + } + + async embed(member, player, matches) { + const embed = util.vainsocialEmbed(`${member.player.name} - ${member.player.shard_id}`, + "", "vainsocial-guild-memberview") + .addField("Guild Profile", await this.text(member)) + .addField("Player Profile", await new PlayerView().text(player)) + .addField("Last Match", await new MatchesView().text(matches[0])); + return embed; + } + + async respond(member, player, matches) { + this.response = await util.respond(this.msg, + await this.embed(member, player, matches), this.response); + return this.response; + } +} diff --git a/views/guild_progress.js b/views/guild_progress.js new file mode 100644 index 0000000..b3fdeb3 --- /dev/null +++ b/views/guild_progress.js @@ -0,0 +1,30 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const GuildMembersProgressView = module.exports; + +module.exports = class extends View { + constructor(msg, players) { + super(msg); + this.players = players; + } + + // players: obj, key=ign, value=progress + async text(players) { + return Object.entries(players).map((tuple) => + `${tuple[0]}: ${tuple[1]}`).join("\n"); + } + + async respond(extra="") { + this.response = await util.respond(this.msg, + await this.text(this.players) + "\n" + extra, this.response); + return this.response; + }; +} diff --git a/views/guild_update.js b/views/guild_update.js new file mode 100644 index 0000000..cb32dc3 --- /dev/null +++ b/views/guild_update.js @@ -0,0 +1,34 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const GuildAddView = module.exports; + +// match detail view +module.exports = class extends View { + constructor(msg, players) { + super(msg); + this.players = players; + } + + // players: obj, key=ign, value=player + async text(players) { + return Object.entries(players).map((tuple) => + (tuple[1] == undefined)? + `Loading ${tuple[0]}…` + : `Loaded ${tuple[0]}.` + ).join("\n"); + } + + async respond() { + this.response = await util.respond(this.msg, + await this.text(this.players), this.response); + return this.response; + }; +} diff --git a/views/match.js b/views/match.js new file mode 100644 index 0000000..2dc55ec --- /dev/null +++ b/views/match.js @@ -0,0 +1,53 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const PREVIEW = process.env.PREVIEW != "false", + ROOTURL = (PREVIEW? "https://preview.vainsocial.com/":"https://vainsocial.com/"); + +const MatchView = module.exports; + +// match detail view +module.exports = class extends View { + // return [[title, text], …] for rosters + async text(match) { + let resps = []; + for(let roster of match.rosters) { + let winstr = "Won"; + if (!roster.winner) winstr = "Lost"; + let rosterstr = `${roster.side} - \`${roster.hero_kills}\` Kills - ${winstr}`; + let teamstr = ""; + for(let participant of roster.participants) { + const hero = await api.mapActor(participant.actor), + emojiScore = strings.emojifyScore(participant.stats.impact_score); + teamstr += ` +\`${hero}\`, [${participant.player.name}](${ROOTURL}player/${participant.player.name}${util.track("match-detail")}) \`T${Math.floor(participant.skill_tier/3+1)}\` | \`${participant.stats.kills}/${participant.stats.deaths}/${participant.stats.assists}\`, \`${Math.floor(participant.stats.farm)}\`, Score ${emojiScore} \`${Math.floor(100 * participant.stats.impact_score)}%\``; + } + resps.push([rosterstr, teamstr]); + } + return resps; + } + + async embed(match) { + let embed = util.vainsocialEmbed(`${match.game_mode}, ${match.duration} minutes`, + "matches/" + match.api_id , "vainsocial-match") + .setTimestamp(new Date(match.created_at)); + (await this.text(match)).forEach(([title, text]) => { + embed.addField(title, text, true); + }); + return embed; + }; + + async respond(matchid) { + const match = await api.getMatch(matchid); + this.response = await util.respond(this.msg, + await this.embed(match), this.response); + return this.response; + }; +} diff --git a/views/matches.js b/views/matches.js new file mode 100644 index 0000000..a79654b --- /dev/null +++ b/views/matches.js @@ -0,0 +1,80 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const emoji = require("discord-emoji"), + Promise = require("bluebird"), + View = require("./view"), + MatchView = require("./match"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"); + +const MATCH_HISTORY_LEN = parseInt(process.env.MATCH_HISTORY_LEN) || 3; + +const MatchesView = module.exports; + +// match detail view +module.exports = class extends View { + constructor(msg, ign) { + super(msg); + this.ign = ign; + } + + async text(participant) { + let winstr = "Won", + hero = await api.mapActor(participant.actor), + game_mode = await api.mapGameMode(participant.game_mode_id), + emojiScore = strings.emojifyScore(participant.stats.impact_score); + if (!participant.winner) winstr = "Lost"; + return ` +${winstr} ${game_mode} with \`${hero}\` +KDA, CS | \`${participant.stats.kills}/${participant.stats.deaths}/${participant.stats.assists}\`, \`${Math.round(participant.stats.farm)}\` +Kill Participation | \`${Math.floor(100 * participant.stats.kill_participation)}%\` +Score | ${emojiScore} \`${Math.floor(100 * participant.stats.impact_score)}%\` +`; + } + + async embed(matches) { + const matchesPart = matches.slice(0, MATCH_HISTORY_LEN); + + // build embed + let embed = util.vainsocialEmbed(this.ign, + "player/" + this.ign, "vainsocial-matches") + .setDescription(`Last ${matchesPart.length} casual and ranked matches.\n` + + await this.help()) + .setTimestamp(new Date(matchesPart[0].created_at)); + await Promise.each(matchesPart, async (match, idx) => + embed.addField(`Match ${idx + 1}`, await this.text(match)) + ); + return embed; + } + + async help() { + return `*${emoji.symbols["1234"]} or ${util.usg(this.msg, "vm " + this.ign + " number")} for details*` + } + + async buttons(matches) { + const matchesPart = matches.slice(0, MATCH_HISTORY_LEN); + + let reactions = {}; + matchesPart.forEach((m, idx) => + reactions[strings.emojiCount[idx]] = async () => { + util.trackAction(this.msg, "reaction-match", m.match_api_id); + await new MatchView(this.msg).respond(m.match_api_id); + }); + return reactions; + } + + async respond() { + const matches = await api.getMatches(this.ign); + this.response = await util.respond(this.msg, + await this.embed(matches), this.response); + if (!this.hasButtons) { + await util.reactionButtons(this.response, + await this.buttons(matches), this.msg); + this.hasButtons = true; + } + return this.response; + } +} diff --git a/views/player.js b/views/player.js new file mode 100644 index 0000000..a481c69 --- /dev/null +++ b/views/player.js @@ -0,0 +1,105 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const emoji = require("discord-emoji"), + View = require("./view"), + MatchView = require("./match"), + MatchesView = require("./matches"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const PREVIEW = process.env.PREVIEW != "false", + ROOTURL = (PREVIEW? "https://preview.vainsocial.com/":"https://vainsocial.com/"); + +const PlayerView = module.exports; + +// player profile +module.exports = class extends View { + constructor(msg, ign) { + super(msg); + this.ign = ign; + } + + async text(player) { + const stats = oneLine` + Win Rate | \`${Math.round(100 * + player.currentSeries.reduce((t, s) => t + s.wins, 0) / + player.currentSeries.reduce((t, s) => t + s.played, 0) + )}%\` + `, + total_kda = oneLine` + Total KDA | \`${player.stats.kills}\` / \`${player.stats.deaths}\` / \`${player.stats.assists}\` + `; + let best_hero = "", + picks = ""; + if (player.best_hero.length > 0) + best_hero = oneLine` + Best | \`${player.best_hero[0].name}\` + `; + if (player.picks.length > 0) { + const hero = await api.mapActor(player.picks[0].actor); + picks = oneLine`Favorite | \`${hero}\`, \`${player.picks[0].hero_pick} picks\``; + } + return ` +${stats} +${total_kda} +${best_hero} +${picks} + `; + } + + async embed(player, matches) { + return util.vainsocialEmbed(`${player.name} - ${player.shard_id}`, "player/" + player.name, "vainsocial-user") + .setThumbnail(ROOTURL + "images/game/skill_tiers/" + + matches[0].skill_tier + ".png") + .setDescription("") + .addField(strings.profile, await this.text(player), true) + .addField(strings.lastMatch, await new MatchesView().text(matches[0]) + + "\n" + await this.help(), true) + .setTimestamp(new Date(matches[0].created_at)); + }; + + async help() { + return oneLine` +*${emoji.symbols.information_source} or ${util.usg(this.msg, "vm " + this.ign)} for detail, +${emoji.symbols["1234"]} or ${util.usg(this.msg, "vh " + this.ign)} for more*`; + } + + async buttons(player, matches) { + let reactions = {}; + reactions[emoji.symbols.information_source] = async () => { + util.trackAction(this.msg, "reaction-match", player.name); + await new MatchView(this.msg).respond(matches[0].match_api_id); + }; + reactions[emoji.symbols["1234"]] = async () => { + util.trackAction(this.msg, "reaction-matches", player.name); + await new MatchesView(this.msg, player.name).respond(); + }; + return reactions; + } + + async respond() { + let player, matches; + try { + [player, matches] = await Promise.all([ + api.getPlayer(this.ign), + api.getMatches(this.ign) + ]); + } catch (err) { + this.response = await util.respond(this.msg, + strings.loading(this.ign), this.response); + return this.response; + } + this.response = await util.respond(this.msg, + await this.embed(player, matches), this.response); + if (!this.hasButtons) { + await util.reactionButtons(this.response, + await this.buttons(player, matches)); + this.hasButtons = true; + } + return this.response; + } +} diff --git a/views/register.js b/views/register.js new file mode 100644 index 0000000..a636e84 --- /dev/null +++ b/views/register.js @@ -0,0 +1,46 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const emoji = require("discord-emoji"), + View = require("./view"), + PlayerView = require("./player"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const RegisterView = module.exports; + +// user register view +module.exports = class extends View { + constructor(msg, ign) { + super(msg); + this.ign = ign; + } + + async text() { + return `You are now registered at VainSocial, ${this.msg.author.toString()}.`; + } + async help() { + return `*${emoji.symbols.repeat} or ${util.usg(this.msg, "v")} to view your profile, ${util.usg(this.msg, "vgcreate")} to create a Guild*` + } + async buttons() { + let reactions = {}; + reactions[emoji.symbols.repeat] = async () => { + util.trackAction(this.msg, "reaction-player"); + await new PlayerView(this.msg, this.ign).respond(); + }; + return reactions; + } + async respond() { + this.response = await util.respond(this.msg, + await this.text() + "\n" + await this.help(), this.response); + if (!this.hasButtons) { + await util.reactionButtons(this.response, + await this.buttons()); + this.hasButtons = true; + } + return this.response; + }; +} diff --git a/views/simple.js b/views/simple.js new file mode 100644 index 0000000..5e8701a --- /dev/null +++ b/views/simple.js @@ -0,0 +1,24 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const View = require("./view"), + util = require("../util"), + api = require("../api"), + strings = require("../strings"), + oneLine = require("common-tags").oneLine; + +const SimpleView = module.exports; + +// just respond with a text +module.exports = class extends View { + async text(txt) { + return txt; + } + + async respond(text) { + this.response = await util.respond(this.msg, + await this.text(text), this.response); + return this.response; + } +} diff --git a/views/view.js b/views/view.js new file mode 100644 index 0000000..771e3db --- /dev/null +++ b/views/view.js @@ -0,0 +1,47 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const util = require("../util"); + +module.exports = class { + constructor(msg) { + // command message + this.msg = msg; + // response (d'oh) + this.response = undefined; + this.hasButtons = false; + } + static async text() { + // return the markdown or an array of [title, markdown] + return ""; + } + async embed() { + // return an embed using `text()` and `help()` + return util.vainsocialEmbed("title", "url"); + } + async help() { + // return explanation for buttons + return ""; + } + async buttons() { + // return buttons pointing to different views + // key: emoji, value: async func + return {}; + } + async respond() { + // reply with embed + buttons + this.response = await util.respond(this.msg, + await this.embed(), this.response); + if (!this.hasButtons) { + await util.reactionButtons(this.response, await this.buttons(), this.msg); + this.hasButtons = true; + } + return this.response; + } + async error(text) { + // reply with error + this.response = await util.respond(this.msg, text, this.response); + return this.response; + } +}; |
