summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api.js111
-rw-r--r--bot.js17
-rw-r--r--commands/about.js21
-rw-r--r--commands/vainsocial/match.js3
-rw-r--r--package.json2
-rw-r--r--responses.js358
6 files changed, 343 insertions, 169 deletions
diff --git a/api.js b/api.js
index 37ed6ae..a13eb8c 100644
--- a/api.js
+++ b/api.js
@@ -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 });
}
diff --git a/bot.js b/bot.js
index 8b18d20..a7bd8e5 100644
--- a/bot.js
+++ b/bot.js
@@ -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);
}