diff options
| -rw-r--r-- | api.js | 111 | ||||
| -rw-r--r-- | bot.js | 17 | ||||
| -rw-r--r-- | commands/about.js | 21 | ||||
| -rw-r--r-- | commands/vainsocial/match.js | 3 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | responses.js | 358 |
6 files changed, 343 insertions, 169 deletions
@@ -3,11 +3,19 @@ "use strict"; const request = require("request-promise-native"), + Promise = require("bluebird"), WebSocket = require("ws"), webstomp = require("webstomp-client"), + cacheManager = require("cache-manager"), Channel = require("async-csp").Channel; +let cache = cacheManager.caching({ + store: "memory", + ttl: 10 // s +}); + const API_FE_URL = process.env.API_FE_URL || "http://vainsocial.dev/bots/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"; @@ -22,6 +30,13 @@ const notif = webstomp.over(new WebSocket(API_WS_URL, })(); // TODO use keepalive / connection pool +function getMap(url) { + return request({ + uri: API_MAP_URL + url, + json: true + }); +} + function getFE(url) { return request({ uri: API_FE_URL + url, @@ -43,45 +58,85 @@ function subscribe(topic, channel) { }, {"ack": "client"}); } -// be an async generator -// next() returns player data whenever an update is available -module.exports.searchPlayer = async function (name, timeout=60) { - let channel = new Channel(), +// return id<->name mappings +async function getMappings() { + return await cache.wrap("mappings", async () => { + let mapping = new Map(); + await Promise.map( + ["gamemode"], async (table) => { + mapping[table] = new Map(); + (await getMap(table)).map( + (map) => mapping[table]["id"] = map["name"]) + } + ); + return mapping; + }); +} + +module.exports.mapGameMode = async function(id) { + return (await getMappings())["gamemode"][id]; +} + +// be an async iterator +// next() returns promises that are awaited until there is an update +module.exports.subscribeUpdates = function(name, timeout=60) { + const channel = new Channel(), subscription = subscribe("player." + name, channel); - await postBE("/player/" + name + "/update"); + // stop updates after timeout - setTimeout(() => { - channel.close(); - subscription.unsubscribe(); - }, timeout*1000); - channel.put("initial"); // initial fetch + setTimeout(() => channel.close(), timeout*1000); - let generator = async () => { - let msg; - while (true) { - msg = await channel.take(); - if (["initial", "search_fail", "stats_update", - "matches_update"].indexOf(msg) != -1) - break; + let msg; + return { next: async function () { + if (this._first) { + this._first = false; + await postBE("/player/" + name + "/update"); + return true; } - if (msg == "search_fail") { - throw "not found"; + do { + msg = await channel.take(); + } while([Channel.DONE, "initial", "search_fail", + "stats_update", "matches_update"].indexOf(msg) == -1); + // bust caches + if (["stats_update"].indexOf(msg) != -1) + cache.del("player+" + name); + if (["matches_update"].indexOf(msg) != -1) { + cache.del("matches+" + name); + cache.del("player+" + name); } - if (msg == Channel.DONE) { - throw "exhausted"; + if ([Channel.DONE, "search_fail"].indexOf(msg) != -1) { + subscription.unsubscribe(); + return undefined; } - return getFE("/player/" + name); - } + return true; + }, _first: true }; +} - return { next: generator }; +// return player +module.exports.getPlayer = async function(name) { + return await cache.wrap("player+" + name, async () => { + try { + return await getFE("/player/" + name); + } catch (err) { + return undefined; + } + }, { ttl: 60 }); } // return matches -module.exports.searchMatches = async function (name) { - return getFE("/player/" + name + "/matches/1.1.1.1"); +module.exports.getMatches = async function(name) { + return await cache.wrap("matches+" + name, async () => { + try { + return await getFE("/player/" + name + "/matches/1.1.1.1"); + } catch (err) { + return undefined; + } + }, { ttl: 60 }); } // return single match -module.exports.searchMatch = async function (id) { - return getFE("/match/" + id); +module.exports.getMatch = async function(id) { + return await cache.wrap("match+" + id, async () => + getFE("/match/" + id), + { ttl: 60 * 60 }); } @@ -2,14 +2,20 @@ /* jshint esnext:true */ "use strict"; +const PREVIEW = process.env.PREVIEW || true; + const sqlite = require("sqlite"), path = require("path"), Commando = require("discord.js-commando"), oneLine = require("common-tags").oneLine, client = new Commando.Client({ - owner: "227440521704898561", // shutterfly - commandPrefix: "?" - }); + owners: ["227440521704898561", "208974925199966208"], + // shutterfly, stormcaller + commandPrefix: (PREVIEW? "?" : "!"), + invite: "https://discord.gg/txTchJY", + unknownCommandResponse: false + }), + responses = require("./responses"); const DISCORD_TOKEN = process.env.DISCORD_TOKEN; @@ -52,7 +58,10 @@ client ${enabled ? 'enabled' : 'disabled'} ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. `); - }); + }) + + // response reaction interface + .on("messageReactionAdd", responses.onNewReaction); client.setProvider( sqlite.open(path.join(__dirname, "settings.sqlite3")).then( diff --git a/commands/about.js b/commands/about.js new file mode 100644 index 0000000..3e8be67 --- /dev/null +++ b/commands/about.js @@ -0,0 +1,21 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + oneLine = require("common-tags").oneLine, + responses = require("../responses"); + +module.exports = class ShowMatchCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "about", + group: "util", + memberName: "about", + description: "Shows invite links and developer contact details." + }); + } + async run(msg) { + await responses.showAbout(msg); + } +}; diff --git a/commands/vainsocial/match.js b/commands/vainsocial/match.js index c8167ea..8f4a5a4 100644 --- a/commands/vainsocial/match.js +++ b/commands/vainsocial/match.js @@ -14,9 +14,6 @@ module.exports = class ShowMatchCommand extends Commando.Command { group: "vainsocial", memberName: "vainsocial-match", description: "Show a user's match in detail.", - details: oneLine` - todo - `, examples: ["vm shutterfly 1"], argsType: "multiple", argsCount: 2, diff --git a/package.json b/package.json index 6630525..13ee525 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "license": "UNLICENSED", "dependencies": { "async-csp": "^0.5.0", + "bluebird": "^3.5.0", "bufferutil": "^3.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": "^0.9.0", diff --git a/responses.js b/responses.js index 5800650..15e7ec7 100644 --- a/responses.js +++ b/responses.js @@ -4,21 +4,38 @@ const Commando = require("discord.js-commando"), Discord = require("discord.js"), + Promise = require("bluebird"), emoji = require("discord-emoji"), oneLine = require("common-tags").oneLine, + Channel = require("async-csp").Channel, api = require("./api"); +const PREVIEW = process.env.PREVIEW || true, + MATCH_HISTORY_LEN = parseInt(process.env.MATCH_HISTORY_LEN) || 3; + +const reactionsPipe = new Channel(); + +// embed template function vainsocialEmbed(title, link) { - return new Discord.RichEmbed() + let embed = new Discord.RichEmbed() .setTitle(title) - .setURL("https://vainsocial.com/" + link) - .setColor("#55ADD3") - .setAuthor("VainSocial", null, "https://vainsocial.com") - .setFooter("VainSocial") - ; + .setColor("#55ADD3"); + if (PREVIEW) { + embed + .setURL("https://preview.vainsocial.com/" + link) + .setAuthor("VainSocial preview", null, + "https://preview.vainsocial.com") + .setFooter("VainSocial preview"); + } else { + embed + .setURL("https://vainsocial.com/" + link) + .setAuthor("VainSocial", null, "https://vainsocial.com") + .setFooter("VainSocial"); + } + return embed; } -// returns the shortest version of the usage help +// return the shortest version of the usage help // just '?vm' function usg(msg, cmd) { return msg.anyUsage(cmd, undefined, null); @@ -34,11 +51,12 @@ function emojifyScore(score) { } // return a match overview string -function formatMatch(participant) { - let winstr = "Won"; +async function formatMatch(participant) { + let winstr = "Won", + game_mode = await api.mapGameMode(participant.game_mode_id); if (!participant.winner) winstr = "Lost"; return ` -${winstr} ${participant.game_mode_id} with \`${participant.actor.replace(/\*/g, "")}\` +${winstr} ${game_mode} with \`${participant.actor.replace(/\*/g, "")}\` 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 | ${emojifyScore(participant.stats.impact_score)} \`${Math.floor(100 * participant.stats.impact_score)}%\` @@ -46,7 +64,7 @@ Score | ${emojifyScore(participant.stats.impact_score)} \`${Math.floor(100 * par } // return [[title, text], …] for rosters -function formatMatchDetail(match) { +async function formatMatchDetail(match) { let strings = []; for(let roster of match.rosters) { let rosterstr = `${roster.side} - \`${roster.hero_kills}\` Kills`; @@ -89,157 +107,229 @@ ${picks} `; } +// 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 +function awaitReactions(message, emoji, timeout=60) { + const pipeOut = new Channel(); + // stop listening after timeout + setTimeout(() => pipeOut.close(), timeout*1000); + + Promise.each(emoji, async (em) => + message.react(em)); // async in background + + 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; + } + } +} + +// respond or say text or embed +async function respond(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) + await response.edit(data); + } else { + if (new Date(response.embeds[0].createdTimestamp).getTime() + != data.timestamp.getTime()) + // TODO how2 edit embed properly?! + await msg.editResponse(response, { + type: "plain", + content: "", + options: { embed: data } + }); + } + } + return response; +} + +// about +module.exports.showAbout = async (msg) => { + await msg.embed(vainsocialEmbed("About VainSocial", "") + .setDescription( +`Built by the VainSocial development team using the MadGlory API. +Currently running on ${msg.client.guilds.size} servers.`) + .addField("Website", + "https://vainsocial.com", 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) + ); +} + // show player profile and last match module.exports.showUser = async (msg, args) => { - let ign = args.name, - iterator = await api.searchPlayer(ign), - response, player; - while (true) { - try { - player = await iterator.next(); - } catch (err) { - if (err == "not found") { - await msg.say("Could not find player `" + ign + "`."); - break; - } - if (err == "exhausted") - break; - if (err.statusCode == 404) - continue; + const ign = args.name, + waiter = api.subscribeUpdates(ign); + let responded = false, + response, + reactionsAdded = false; + + while (await waiter.next() != undefined) { + const [player, matches] = await Promise.all([ + api.getPlayer(ign), + api.getMatches(ign) + ]); + if (player == undefined) { + response = await respond(msg, + "Loading your data…", response); + continue; } - let matches = (await api.searchMatches(ign)).data; - let matchstr = "not available", - skill_tier = -1, - last_match_date = new Date(); - if (matches.length > 0) { - matchstr = formatMatch(matches[0]); - skill_tier = matches[0].skill_tier; - last_match_date = new Date(matches[0].created_at); + if (matches == undefined || matches.data.length == 0) { + response = await respond(msg, + "No match history for you yet", response); + continue; } - let embed = vainsocialEmbed(`${ign} - ${player.shard_id}`, "player/" + ign) + const moreHelp = oneLine` +*${emoji.symbols.information_source} or ${usg(msg, "vm " + ign)} for detail, +${emoji.symbols["1234"]} or ${usg(msg, "vh " + ign)} for more*` + + const embed = vainsocialEmbed(`${ign} - ${player.shard_id}`, "player/" + ign) .setThumbnail("https://vainsocial.com/images/game/skill_tiers/" + - skill_tier + ".png") + matches.data[0].skill_tier + ".png") .setDescription("") .addField("Profile", formatPlayer(player), true) - .addField("Last match", matchstr + ` -*${emoji.symbols.information_source} or ${usg(msg, "vm " + ign)} for detail, ${emoji.symbols["1234"]} or ${usg(msg, "vh " + ign)} for more* - `, true) - .setTimestamp(last_match_date) - ; + .addField("Last match", await formatMatch(matches.data[0]) + moreHelp, true) + .setTimestamp(new Date(matches.data[0].created_at)); + response = await respond(msg, embed, response); - if (response == undefined) { - response = await msg.embed(embed); - msg.client.on("messageReactionAdd", async (react) => { - if (react.message != response) return; - if (react.users.array().length <= 1) return; // was me - if (react.emoji.name == emoji.symbols.information_source) - await showMatch(msg, { - name: ign, - id: matches[0].match_api_id - }); - if (react.emoji.name == emoji.symbols["1234"]) - await showMatches(msg, { name: ign }); - }); - await response.react(emoji.symbols.information_source); - await response.react(emoji.symbols["1234"]); - } else { - response = await msg.editResponse(response, { - type: "plain", - content: "", - options: { embed: embed } - }); + if (!reactionsAdded) { + reactionsAdded = true; + let reactionWaiter = awaitReactions(response, + [emoji.symbols.information_source, emoji.symbols["1234"]]); + (async () => { + while (true) { + let rmoji = await reactionWaiter.next(); + if (rmoji == undefined) break; // timeout + if (rmoji == emoji.symbols.information_source) + await respondMatch(msg, ign, matches.data[0].match_api_id); + if (rmoji == emoji.symbols["1234"]) + await respondMatches(msg, ign); + } + })(); // async in background } } + if (!reactionsAdded) + await respond(msg, `Could not find \`${ign}\`.`, response); } // show match in detail -let showMatch = module.exports.showMatch = async (msg, args) => { - let response, - ign = args.name, - id = args.id, - index = args.number; +async function respondMatch(msg, ign, id, response=undefined) { + const match = await api.getMatch(id); - let match; - try { - if (id == undefined) - id = (await api.searchMatches(ign)).data[index - 1].match_api_id; - match = await api.searchMatch(id); - } catch (err) { - await msg.say(oneLine` - Ooops! I don't have any data for you yet. - Please take a look at your profile first! - ${usg(msg, "v " + ign)} - `); // TODO! - return; - } let embed = vainsocialEmbed(`${match.game_mode}, ${match.duration} minutes`, "match/" + id) - .setTimestamp(new Date(match.created_at)) - formatMatchDetail(match).forEach(([title, text]) => { + .setTimestamp(new Date(match.created_at)); + (await formatMatchDetail(match)).forEach(([title, text]) => { embed.addField(title, text, true); }); - ; - response = await msg.editResponse(response, { - type: "plain", - content: "", - options: { embed: embed } - }); + return await respond(msg, embed, response); } -// show match history -let showMatches = module.exports.showMatches = async (msg, args) => { - let ign = args.name, +module.exports.showMatch = async (msg, args) => { + const ign = args.name, + index = args.number, + waiter = api.subscribeUpdates(ign); + let responded = false, response; - let count = [ - 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, + + while (await waiter.next() != undefined) { + let matches = await api.getMatches(ign); + if (matches == undefined || matches.data.length == 0) { + response = await respond(msg, "No matches for you yet", + response); + continue; + } + if (index - 1 > matches.data.length) { + response = await respond(msg, "Not enough matches yet", + response); + continue; + } + let id = matches.data[index -1].match_api_id; + response = await respondMatch(msg, ign, id, response); + responded = true; + } + if (!responded) + await respond(msg, `Could not find \`${ign}\`.`, response); +} + +// show match history +async function respondMatches(msg, ign, response=undefined) { + const count = [ 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 ]; - let data, matches, matches_num; - try { - data = await api.searchMatches(ign); - } catch (err) { - await msg.say(oneLine` - Ooops! I don't have any data for you yet. - Please take a look at your profile first! - ${usg(msg, "v " + ign)} - `); // TODO! - return; - } - matches = data.data.slice(0, 3); - matches_num = matches.length; + const data = await api.getMatches(ign), + matches = data.data.slice(0, MATCH_HISTORY_LEN), + matches_num = matches.length; let embed = vainsocialEmbed(ign, "player/" + ign) .setDescription(` Last ${matches_num} casual and ranked matches. *${emoji.symbols["1234"]} or ${usg(msg, "vm " + ign + " number")} for details* - `); - matches.forEach((match, idx) => - embed.addField(`Match ${idx + 1}`, formatMatch(match))); - response = await msg.editResponse(response, { - type: "plain", - content: "", - options: { embed: embed } - }); + `) + .setTimestamp(new Date(matches[0].created_at)); + await Promise.each(matches, async (match, idx) => + embed.addField(`Match ${idx + 1}`, await formatMatch(match)) + ); + response = await respond(msg, embed, response); - msg.client.on("messageReactionAdd", async (react) => { - if (react.message != response) return; - if (react.users.array().length <= 1) return; // was me - let idx = count.indexOf(react.emoji.name); - if (idx == -1) return; - await showMatch(msg, { - name: ign, - id: matches[idx].match_api_id - }); - }); + const reactionWaiter = awaitReactions(response, + count.slice(0, matches_num)); + (async () => { + while (true) { + let rmoji = await reactionWaiter.next(); + if (rmoji == undefined) break; // timeout + let idx = count.indexOf(rmoji); + await respondMatch(msg, ign, matches[idx].match_api_id); + } + })(); // async in background + return response; +} + +module.exports.showMatches = async (msg, args) => { + const ign = args.name, + waiter = api.subscribeUpdates(ign); + let responded = false, + response; - for (let num of count.slice(0, matches_num)) - await response.react(num) + while (await waiter.next() != undefined) { + let matches = await api.getMatches(ign); + if (matches == undefined || matches.data.length == 0) { + response = await respond(msg, "No matches for you yet", + response); + continue; + } + response = await respondMatches(msg, ign, response); + responded = true; + } + if (!responded) + await respond(msg, `Could not find \`${ign}\`.`, + response); } |
