From 0564241327ecc6eb08cd716f510ebb0afbb3eeb3 Mon Sep 17 00:00:00 2001 From: schneefux Date: Tue, 11 Apr 2017 21:21:22 +0200 Subject: draft: rewrite in node --- .gitignore | 129 +++++++++++------------------ api.js | 73 ++++++++++++++++ bot.js | 66 +++++++++++++++ commands/vainsocial/match.js | 41 +++++++++ commands/vainsocial/matches.js | 34 ++++++++ commands/vainsocial/user.js | 33 ++++++++ main.py | 184 ----------------------------------------- package.json | 27 ++++++ requirements.txt | 14 ---- responses.js | 156 ++++++++++++++++++++++++++++++++++ settings.sqlite3 | Bin 0 -> 8192 bytes 11 files changed, 478 insertions(+), 279 deletions(-) create mode 100644 api.js create mode 100644 bot.js create mode 100644 commands/vainsocial/match.js create mode 100644 commands/vainsocial/matches.js create mode 100644 commands/vainsocial/user.js delete mode 100644 main.py create mode 100644 package.json delete mode 100644 requirements.txt create mode 100644 responses.js create mode 100644 settings.sqlite3 diff --git a/.gitignore b/.gitignore index a3401aa..2788ca5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,93 +1,60 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# 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 - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: +# Logs +logs *.log -!/logs -!run*.log -local_settings.py +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# Flask stuff: -instance/ -.webassets-cache +# Runtime data +pids +*.pid +*.seed +*.pid.lock -# Scrapy stuff: -.scrapy +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov -# Sphinx documentation -docs/_build/ +# Coverage directory used by tools like istanbul +coverage -# PyBuilder -target/ +# nyc test coverage +.nyc_output -# Jupyter Notebook -.ipynb_checkpoints +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt -# pyenv -.python-version +# Bower dependency directory (https://bower.io/) +bower_components -# celery beat schedule file -celerybeat-schedule +# node-waf configuration +.lock-wscript -# dotenv -.env +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm -# virtualenv -.venv/ -venv/ -ENV/ +# Optional eslint cache +.eslintcache -# Spyder project settings -.spyderproject +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env -# Rope project settings -.ropeproject +.sqlite3 diff --git a/api.js b/api.js new file mode 100644 index 0000000..fef1ffe --- /dev/null +++ b/api.js @@ -0,0 +1,73 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const request = require("request-promise-native"), + WebSocket = require("ws"), + webstomp = require("webstomp-client"), + Channel = require("async-csp").Channel; + +const API_FE_URL = process.env.API_FE_URL || "http://vainsocial.dev/bots/api", + 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 ws = new WebSocket(API_WS_URL, { perMessageDeflate: false }), + notif = webstomp.over(ws); + +function connect() { + notif.connect("web", "web", + () => console.log("connected to queue"), + (err) => console.error("error connecting to queue", err)); + keepalive(); +} + +function keepalive() { + ws.ping("", false, true); + setTimeout(this, 60000); +} + +ws.on("ready", connect); + +// TODO use keepalive / connection pool +async function getFE(url) { + return await request({ + uri: API_FE_URL + url, + json: true + }); +} + +async function postBE(url) { + return await request.post({ + uri: API_BE_URL + url, + json: true + }); +} + +function subscribe(topic, channel) { + notif.subscribe("/topic/" + topic, async (msg) => { + await channel.put(msg.body); + msg.ack(); + }, {"ack": "client"}); +} + +module.exports.searchPlayer = async function (name, timeout=30) { + let channel = new Channel(); + + subscribe("player." + name, channel); + await postBE("/player/" + name + "/search"); + setTimeout(() => channel.close(), timeout*1000); + channel.put(""); // initial fetch + + let generator = async () => { + let msg = await channel.take(); + if (msg == "search_fail") { + throw "not found"; + } + if (msg == Channel.DONE) { + throw "exhausted"; + } + return await getFE("/player/" + name); + } + + return { next: generator }; +} diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..c3826c1 --- /dev/null +++ b/bot.js @@ -0,0 +1,66 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +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: "?" + }); + +const DISCORDTOKEN = process.env.DISCORDTOKEN || "Mjg5ODM2OTAwOTg5MDA5OTIw.C6SLKA.j8UETpPHztDV45xicf11hwpwNK8"; + +client + .on("error", console.error) + .on("warn", console.warn) + .on("debug", console.log) + .on("ready", () => { + console.log(`Client ready; logged in as ${client.user.username}#${client.user.discriminator} (${client.user.id})`); + }) + .on('disconnect', () => { console.warn('Disconnected!'); }) + .on('reconnecting', () => { console.warn('Reconnecting...'); }) + .on('commandError', (cmd, err) => { + if(err instanceof Commando.FriendlyError) return; + console.error(`Error in command ${cmd.groupID}:${cmd.memberName}`, err); + }) + .on('commandBlocked', (msg, reason) => { + console.log(oneLine` + Command ${msg.command ? `${msg.command.groupID}:${msg.command.memberName}` : ''} + blocked; ${reason} + `); + }) + .on('commandPrefixChange', (guild, prefix) => { + console.log(oneLine` + Prefix ${prefix === '' ? 'removed' : `changed to ${prefix || 'the default'}`} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }) + .on('commandStatusChange', (guild, command, enabled) => { + console.log(oneLine` + Command ${command.groupID}:${command.memberName} + ${enabled ? 'enabled' : 'disabled'} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }) + .on('groupStatusChange', (guild, group, enabled) => { + console.log(oneLine` + Group ${group.id} + ${enabled ? 'enabled' : 'disabled'} + ${guild ? `in guild ${guild.name} (${guild.id})` : 'globally'}. + `); + }); + +client.setProvider( + sqlite.open(path.join(__dirname, "settings.sqlite3")).then( + db => new Commando.SQLiteProvider(db))); + +client.registry + .registerGroup('vainsocial', 'VainSocial') + .registerDefaults() + .registerCommandsIn(path.join(__dirname, 'commands')); + + +client.login(DISCORDTOKEN); diff --git a/commands/vainsocial/match.js b/commands/vainsocial/match.js new file mode 100644 index 0000000..14b06a2 --- /dev/null +++ b/commands/vainsocial/match.js @@ -0,0 +1,41 @@ +#!/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: "vainsocial-match", + aliases: ["vm"], + group: "vainsocial", + memberName: "vainsocial-match", + description: "Show a user's match in detail.", + details: oneLine` + todo + `, + examples: ["vm shutterfly 1"], + argsType: "multiple", + argsCount: 2, + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string" + }, { + 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 + } ] + }); + } + async run(msg, args) { + await responses.showMatch(msg, args); + } +}; diff --git a/commands/vainsocial/matches.js b/commands/vainsocial/matches.js new file mode 100644 index 0000000..2660d73 --- /dev/null +++ b/commands/vainsocial/matches.js @@ -0,0 +1,34 @@ +#!/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 ShowMatchesCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-history", + aliases: ["vh"], + group: "vainsocial", + memberName: "vainsocial-matches", + description: "Show a user's match history.", + details: oneLine` + todo + `, + examples: ["vh shutterfly"], + argsType: "single", + + args: [ { + key: "name", + label: "name", + prompt: "Please specify your in game name (Case Sensitive).", + type: "string" + } ] + }); + } + async run(msg, args) { + await responses.showMatches(msg, args); + } +}; diff --git a/commands/vainsocial/user.js b/commands/vainsocial/user.js new file mode 100644 index 0000000..f544a5e --- /dev/null +++ b/commands/vainsocial/user.js @@ -0,0 +1,33 @@ +#!/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 ShowUserCommand extends Commando.Command { + constructor(client) { + super(client, { + name: "vainsocial-user", + aliases: ["v", "vu"], + group: "vainsocial", + memberName: "vainsocial-user", + description: "Show 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" + } ] + }); + } + async run(msg, args) { + await responses.showUser(msg, args); + } +}; diff --git a/main.py b/main.py deleted file mode 100644 index a80dc92..0000000 --- a/main.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import os -import concurrent.futures -import http -import asyncio -import asyncpg -import discord -from discord.ext import commands -from socketIO_client import SocketIO, LoggingNamespace - -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" -} - -TIMEOUT = 120 # s until giving up on API - -bot = commands.Bot( - command_prefix="!", - description="Vainsocial Vainglory stats bot") - -pool = None -threadpool = concurrent.futures.ThreadPoolExecutor( - max_workers=10) - - -@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) - logging.warning("connecting to database") - global pool - pool = await asyncpg.create_pool( - min_size=2, **db) - await bot.change_presence( - game=discord.Game( - name="vainsocial.com")) - - -@bot.command() -async def about(): - """Print invite links.""" - emb = discord.Embed( - title="Vainsocial Discord bot", - description="Built by the Vainsocial development team using the MadGlory API. Currently running on %i servers." % (len(bot.servers),) - ) - emb.add_field(name="Website", - value="[vainsocial.com](https://vainsocial.com/?utm_source=discord&utm_medium=vainsocial)") - emb.add_field(name="Bot invite link", - value="[discordapp.com](https://discordapp.com/oauth2/authorize?&client_id=287297889024213003&scope=bot)") - emb.add_field(name="Developer Discord", - value="[discord.me/vainsocial](https://discord.me/vainsocial)") - emb.add_field(name="Twitter", - value="[twitter/vainsocial](https://twitter.com/vainsocial)") - await bot.say(embed=emb) - - -@bot.command(aliases=["v", "vain"]) -async def vainsocial(name: str): - """Retrieves a player's stats.""" - 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 - 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 (%(shard_id)s)" % data, - description="Last match registered (GMT): %(last_match_created_date)s" % data, - url="https://vainsocial.com/players/%(shard_id)s/%(name)s/?utm_source=discord&utm_medium=vainsocial" % data - ) - emb.set_author(name="Vainsocial", - url="https://vainsocial.com") - emb.add_field(name="Profile", - value=("%(wins)i wins / %(played)i games\n" + - "[View on vainsocial.com](https://vainsocial.com/players/%(shard_id)s/%(name)s/?utm_source=discord&utm_medium=vainsocial)") % data) - emb.add_field(name="Last match", - value=("%(result)s %(mode)s as %(hero)s %(kills)i/%(deaths)i/%(assists)i\n" + - "[View on vainsocial.com](https://vainsocial.com/matches/%(match_api_id)s/?utm_source=discord&utm_medium=vainsocial)") % data) - - emb.set_footer(text="Vainsocial - Vainglory social stats service") - emb.set_thumbnail(url="https://vainsocial.com/images/game/skill_tiers/%(skill_tier)s.png" % data) - return emb - - async with pool.acquire() as con: - await bot.type() - - bot_response = await bot.say("Loading…") - vainsocial.wait_for_update = True # continue polling - vainsocial.do_update = True # poll immediately - - async def request_update(): - # request an update via Vainsocial API - api_con = http.client.HTTPConnection("localhost", 8080) - api_con.request("HEAD", "/api/player/name/" + name) - api_resp = api_con.getresponse() - logging.info("%s: API responded with status %i", - name, api_resp.status) - if api_resp.status == 404: - vainsocial.wait_for_update = False - await bot.edit_message(bot_response, - "Could not find you.") - api_con.close() - - def update_available(*args): - if args[0] in ["process_finished", "compile_finished"]: - # do partial update - vainsocial.do_update = True - if args[0] == "done": - vainsocial.wait_for_update = False - - - io = SocketIO("localhost", 8080, LoggingNamespace) - io.on(name, update_available) - - socket_waiter = asyncio.ensure_future( - bot.loop.run_in_executor(threadpool, io.wait, TIMEOUT)) - asyncio.ensure_future(request_update()) - - has_embed = False - poll_frequency = 5 # Hz - for _ in range(TIMEOUT*poll_frequency): - if vainsocial.do_update: - logging.info("%s: updating bot response", name) - vainsocial.do_update = False - data = await con.fetchrow(query, name) - if data: - has_embed = True - await bot.edit_message(bot_response, - embed=emb(data)) - if not vainsocial.wait_for_update: - break - await asyncio.sleep(1.0/poll_frequency) - - socket_waiter.cancel() # stop listening on websocket - - if has_embed: - await bot.edit_message(bot_response, "Up to date.") - # if not, it was a 404 - - -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..ddf79e4 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "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", + "bufferutil": "^3.0.0", + "discord-emoji": "^1.1.1", + "discord.js": "^11.0.0", + "discord.js-commando": "^0.9.0", + "erlpack": "github:hammerandchisel/erlpack", + "request": "^2.81.0", + "request-promise": "^4.2.0", + "request-promise-native": "^1.0.3", + "sqlite": "^2.5.0", + "uws": "^0.14.1", + "webstomp": "0.0.10", + "webstomp-client": "^1.0.6", + "ws": "^2.2.3" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 427dc0d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,14 +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 -requests==2.13.0 -six==1.10.0 -socketIO-client==0.7.2 -websocket-client==0.40.0 -websockets==3.2 diff --git a/responses.js b/responses.js new file mode 100644 index 0000000..f54ea89 --- /dev/null +++ b/responses.js @@ -0,0 +1,156 @@ +#!/usr/bin/node +/* jshint esnext:true */ +"use strict"; + +const Commando = require("discord.js-commando"), + Discord = require("discord.js"), + emoji = require("discord-emoji"), + oneLine = require("common-tags").oneLine, + api = require("./api"); + +function vainsocialEmbed(title, link) { + return new Discord.RichEmbed() + .setTitle(title) + .setURL("https://vainsocial.com/" + link) + .setColor("#55ADD3") + .setAuthor("VainSocial", null, "https://vainsocial.com") + .setFooter("VainSocial - Vainglory social stats service") + ; +} + +// returns the shortest version of the usage help +// just '?vm' +function usg(msg, cmd) { + return msg.anyUsage(cmd, undefined, null); +} + +// TODO, obviously + +// show player profile and last match +module.exports.showUser = async (msg, args) => { + let ign = args.name, + iterator = await api.searchPlayer(ign), + response, data; + while (true) { + try { + data = await iterator.next(); + } catch (err) { + if (err.statusCode == 404) { + await msg.reply("Could not find `" + ign + "`"); + break; + } + if (err == "exhausted") + break; + } + let winstr = "won"; + if (data.last_result[0].winner == false) winstr = "lost"; + + let embed = vainsocialEmbed(ign, "player/" + ign) + .setThumbnail("https://vainsocial.com/images/game/skill_tiers/" + + data.skill_tier + ".png") + .setDescription("") + .addField("Profile", ` + (Player stats will be here) + `) + .addField("Last match", oneLine` + Played ${data.last_result[0].game_mode_id} with ${data.last_result[0].actor}, ${winstr} + *${emoji.symbols.information_source} or ${usg(msg, "vm " + ign)} for more* + `) + .setTimestamp(new Date(data.last_match_created_date)) + ; + + if (response == undefined) { + response = await msg.replyEmbed(embed); + await response.react(emoji.symbols.information_source); + msg.client.on("messageReactionAdd", async (react) => { + if (react.message != response) return; + if (react.emoji != emoji.symbols.information_source) return; + await showMatch(msg, { + name: ign, + id: data.last_result[0].match_api_id + }); + }); + } else { + response = await msg.editResponse(response, { + type: "plain", + content: "", + options: { embed: embed } + }); + } + } +} + +// show match in detail +let showMatch = module.exports.showMatch = async (msg, args) => { + let response, + ign = args.name, + id = args.id, + index = args.number, + match; + if (id != null) match = {}; + else match = {}; // fetch match from API + let embed = vainsocialEmbed("A Match", "match/" + 12345) + .setDescription("A lot of stuff happened here. This was ranked or casual?") + .addField("Left team", ` + Killed some heroes from the right team and someone died + `) + .addField("Right team", ` + Did good work too. Poor minions. + `) + //.setTimestamp(new Date(data.last_match_created_date)) + ; + response = await msg.editResponse(response, { + type: "plain", + content: "", + options: { embed: embed } + }); +} + +// show match history +module.exports.showMatches = async (msg, args) => { + let ign = args.name, + 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, + emoji.symbols.ten + ]; + let embed = vainsocialEmbed(ign, "player/" + ign) + .setDescription(` + Last 1337 casual and ranked matches. + *${emoji.symbols["1234"]} or ${usg(msg, "vm " + ign)} number for details* + `) + .addField("Match 1", ` + Blablabla. Blabla. + `) + .addField("Match 2", ` + Ooooh did a Minion just walk into our base? + `) + ; + response = await msg.editResponse(response, { + type: "plain", + content: "", + options: { embed: embed } + }); + + msg.client.on("messageReactionAdd", async (react) => { + if (react.message != response) return; + if (react.count == 1) return; // was me + let idx = count.indexOf(react.emoji.name); + if (idx == -1) return; + await showMatch(msg, { + name: ign, + number: idx + }); + }); + + for (let num of count) + await response.react(num) +} diff --git a/settings.sqlite3 b/settings.sqlite3 new file mode 100644 index 0000000..15ce44e Binary files /dev/null and b/settings.sqlite3 differ -- cgit v1.3.1