forward console responses to commands

This commit is contained in:
Joseph Montanaro 2020-10-12 05:03:50 +00:00
parent 211d08bd84
commit 38863a92cc
5 changed files with 226 additions and 68 deletions

34
bot.py
View File

@ -1,7 +1,9 @@
import asyncio
import inspect import inspect
import itertools import itertools
import logging import logging
import os import os
import re
import discord import discord
from discord.ext import commands from discord.ext import commands
@ -13,18 +15,23 @@ import heavynode
DISCORD_TOKEN = os.environ['discord_token'] DISCORD_TOKEN = os.environ['discord_token']
DISCORD_SERVER_ID = 530446700058509323 DISCORD_SERVER_ID = 530446700058509323
HEAVYNODE_TOKEN = os.environ['heavynode_token'] HEAVYNODE_TOKEN = os.environ['heavynode_token']
SESSION_COOKIE = os.environ['pterodactyl_session_cookie']
logging.basicConfig() logging.basicConfig()
bot = lib.MineBot(command_prefix='!') intents = discord.Intents.default()
hn = heavynode.Client(HEAVYNODE_TOKEN) intents.members = True
bot = lib.MineBot(command_prefix='!', intents=intents)
hn = heavynode.Client(HEAVYNODE_TOKEN, SESSION_COOKIE)
bot.add_cleanup(hn.shutdown)
async def is_admin(ctx): async def is_admin(ctx):
user = ctx.message.author user = ctx.message.author
guild = discord.utils.get(bot.guilds, id=DISCORD_SERVER_ID) guild = discord.utils.get(bot.guilds, id=DISCORD_SERVER_ID)
member = discord.utils.get(guild.members, id=user.id) member = guild.get_member(user.id)
if member is not None: if member is not None:
for role in member.roles: for role in member.roles:
if role.name == 'Admin' or role.name == 'Mod': if role.name == 'Admin' or role.name == 'Mod':
@ -32,20 +39,27 @@ async def is_admin(ctx):
return False return False
async def whitelist_cmd(action, player):
expr = re.compile(f'\[Server thread/INFO\]: .*({player}|player).*(whitelist(ed)?|exist)', re.IGNORECASE)
resp = await hn.cmd_with_response(f'whitelist {action} {player}', expr)
_, msg = resp.split('[Server thread/INFO]: ')
return msg
@bot.command() @bot.command()
@commands.check(is_admin) @commands.check(is_admin)
async def add(ctx, player): async def add(ctx, player):
"""Add a player to the server whitelist. Must use exact Minecraft name.""" """Add a player to the server whitelist. Must use exact Minecraft name."""
await hn.send_command(f'whitelist add {player}') msg = await whitelist_cmd('add', player)
await ctx.send(f'"{player}" added to whitelist.') await ctx.send(msg)
@bot.command() @bot.command()
@commands.check(is_admin) @commands.check(is_admin)
async def remove(ctx, player): async def remove(ctx, player):
"""Remove a player from the server whitelist. Must use exact Minecraft name.""" """Remove a player from the server whitelist. Must use exact Minecraft name."""
await hn.send_command(f'whitelist remove {player}') msg = await whitelist_cmd('remove', player)
await ctx.send(f'"{player}" removed from whitelist.') await ctx.send(msg + '.')
@add.error @add.error
@ -53,8 +67,12 @@ async def remove(ctx, player):
async def whitelist_error(ctx, error): async def whitelist_error(ctx, error):
if isinstance(error, commands.CheckFailure): if isinstance(error, commands.CheckFailure):
await ctx.send('You must be a server admin to use this command.') await ctx.send('You must be a server admin to use this command.')
elif isinstance(error, heavynode.HttpError): elif isinstance(error.original, (heavynode.HttpError, heavynode.GenericError)):
await ctx.send('Failed to communicate with server.') await ctx.send('Failed to communicate with server.')
elif isinstance(error.original, asyncio.exceptions.TimeoutError):
await ctx.send('Command sent; response inconclusive. Check server output for more information.')
elif isinstance(error.original, ValueError):
await ctx.send('Command sent; could not interpret server response.')
else: else:
raise error raise error

View File

@ -1,60 +0,0 @@
import asyncio
import urllib.parse
import aiohttp
class HttpError(Exception):
def __init__(self, msg, response):
self.response = response
super().__init__(msg)
class Client:
def __init__(self, token):
self.token = token
self.baseurl = 'https://control.heavynode.com/api'
# global state is icky, but it sure is convenient
loop = asyncio.get_event_loop()
loop.create_task(self.fetch_server())
# since we don't start the loop here, this will just
# hang out in a pending state until someone else does
async def make_request(self, method, path, *args, **kwargs):
h = {
'Authorization': f'Bearer {self.token}',
'Accept': 'Application/vnd.pterodactyl.v1+json',
}
if 'headers' in kwargs:
kwargs['headers'].update(h)
else:
kwargs['headers'] = h
if path[0] != '/':
path = '/' + path
url = self.baseurl + path
# use context managers so connection is properly closed after request
# we don't make many requests so this is reasonable
async with aiohttp.ClientSession() as session:
async with session.request(method, url, *args, **kwargs) as r:
if r.status >= 400:
raise HttpError(f'Request failed with status code {r.status}', r)
elif r.status == 204:
return None # no content
elif r.headers['Content-Type'].lower() in {'application/json', 'application/vnd.pterodactyl.v1+json'}:
return await r.json()
else:
return await r.text
async def send_command(self, cmd):
"""Send console command to minecraft server."""
server_id = self.server['identifier']
payload = {'command': cmd}
return await self.make_request('POST', f'/client/servers/{server_id}/command', json=payload)
async def fetch_server(self):
"""Get the server to which we have access.
Assume there's only one."""
r = await self.make_request('GET', '/client')
self.server = r['data'][0]['attributes']

1
heavynode/__init__.py Normal file
View File

@ -0,0 +1 @@
from .main import Client, HttpError, GenericError

38
heavynode/events.py Normal file
View File

@ -0,0 +1,38 @@
import asyncio
import contextlib
class Beacon:
def __init__(self):
self.watchers = set()
@contextlib.contextmanager
def msg_q(self):
q = asyncio.Queue()
self.watchers.add(q)
try:
yield q
finally:
self.watchers.remove(q)
def flash(self, data):
for w in self.watchers:
w.put_nowait(data)
async def watch(self, count=float('inf')):
with self.msg_q() as q:
i = 0
while i < count:
yield await q.get()
i += 1
async def next_flash(self):
async for event in self.watch(1):
return event
async def watch_for(self, predicate, timeout=None):
async def waiter():
async for event in self.watch():
if predicate(event):
return event
return await asyncio.wait_for(waiter(), timeout=timeout)

161
heavynode/main.py Normal file
View File

@ -0,0 +1,161 @@
import asyncio
import contextlib
import collections
import re
import urllib.parse
import aiohttp
import socketio
from . import events
class HttpError(Exception):
def __init__(self, msg, response):
self.response = response
super().__init__(msg)
class GenericError(Exception):
pass
class Socket:
def __init__(self, secret, server_id, buffer_size=300):
self.secret = secret
self.server_id = server_id
self.buffer_size = buffer_size
# event hooks for observing
self.events = collections.defaultdict(events.Beacon)
self.buffers = collections.defaultdict(list)
async def handle_event(self, name, data):
buf = self.buffers[name]
buf.append(data)
if len(buf) > self.buffer_size:
del buf[0]
self.events[name].flash(data)
async def connect_socket(self):
socket = socketio.AsyncClient()
ns = '/v1/ws/' + self.server_id
@socket.event
async def connect():
print('Websocket connected.')
@socket.event
async def disconnect():
print('Websocket disconnected.')
@socket.event(namespace=ns)
async def proc(data):
await self.handle_event('proc', data['data'])
@socket.event(namespace=ns)
async def console(data):
await self.handle_event('console', data['line'])
self.ws = socket
await socket.connect('https://mcp02.lax.us.heavynode.net:8443/socketio?token=' + self.secret, namespaces=[ns])
async def listen(self, event, expression, timeout=60):
if type(expression) == str:
regexp = re.compile(expression)
elif type(expression) == re.Pattern:
regexp = expression
else:
raise ValueError('Expression must be string or compiled regex.')
def test(line):
return regexp.search(line)
return await self.events[event].watch_for(test, timeout=timeout)
async def disconnect(self):
try:
await asyncio.wait_for(self.ws.disconnect(), timeout=5)
except asyncio.TimeoutError:
print('WARNING: Could not disconnect websocket cleanly.')
class Client:
def __init__(self, token, session_cookie):
self.token = token
self.session_cookie = session_cookie
self.baseurl = 'https://control.heavynode.com/api'
self.stats = []
# global state is icky, but it sure is convenient
loop = asyncio.get_event_loop()
loop.create_task(self.setup_async())
# since we don't start the loop here, this will just
# hang out in a pending state until someone else does
async def make_request(self, method, path, *args, **kwargs):
h = {
'Authorization': f'Bearer {self.token}',
'Accept': 'Application/vnd.pterodactyl.v1+json',
}
if 'headers' in kwargs:
kwargs['headers'].update(h)
else:
kwargs['headers'] = h
if path[0] != '/':
path = '/' + path
url = self.baseurl + path
# use context managers so connection is properly closed after request
# we don't make many requests so this is reasonable
async with aiohttp.ClientSession() as session:
try:
async with session.request(method, url, *args, **kwargs) as r:
if r.status >= 400:
raise HttpError(f'Request failed with status code {r.status}', r)
elif r.status == 204:
return None # no content
elif r.headers['Content-Type'].lower() in {'application/json', 'application/vnd.pterodactyl.v1+json'}:
return await r.json()
else:
return await r.text
except Exception as e:
raise GenericError('Something went wrong further down the stack. Original error shown above.') from e
async def send_command(self, cmd):
"""Send console command to minecraft server."""
server_id = self.server['identifier']
payload = {'command': cmd}
return await self.make_request('POST', f'/client/servers/{server_id}/command', json=payload)
async def cmd_with_response(self, cmd, regex):
if type(regex) == str:
regex = re.compile(regex)
elif type(regex) != re.Pattern:
raise ValueError('Expression must be string or compiled regex.')
listener = asyncio.create_task(self.socket.listen('console', regex, timeout=5))
await self.send_command(cmd)
return await asyncio.wait_for(listener, timeout=None)
async def setup_async(self):
"""Get the server to which we have access.
Assume there's only one."""
r = await self.make_request('GET', '/client')
self.server = r['data'][0]['attributes']
print('Added server:', self.server['identifier'])
secret = await self.fetch_daemon_secret()
self.socket = Socket(secret, self.server['uuid'])
await self.socket.connect_socket()
async def fetch_daemon_secret(self):
cookie = {'pterodactyl_session': self.session_cookie}
async with aiohttp.ClientSession(cookies=cookie) as session:
r = await session.get('https://control.heavynode.com/server/' + self.server['identifier'])
m = re.search('"daemonSecret"\s?:\s?"([^"]*)"', await r.text())
if m != None:
return m.groups()[0]
else:
raise GenericError('Heavynode client setup failed: Could not acquire daemon secret.')
async def shutdown(self):
await self.socket.disconnect()