aboutsummaryrefslogtreecommitdiffstats
path: root/pycrctrl.py
diff options
context:
space:
mode:
Diffstat (limited to 'pycrctrl.py')
-rw-r--r--pycrctrl.py593
1 files changed, 593 insertions, 0 deletions
diff --git a/pycrctrl.py b/pycrctrl.py
new file mode 100644
index 0000000..06dcb92
--- /dev/null
+++ b/pycrctrl.py
@@ -0,0 +1,593 @@
+# -*- coding:utf-8 -*-
+
+#Copyright (c) 2017, George Tokmaji
+
+#Permission to use, copy, modify, and/or distribute this software for any
+#purpose with or without fee is hereby granted, provided that the above
+#copyright notice and this permission notice appear in all copies.
+#
+#THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+#WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+#MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+#ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+#WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+#ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+#OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import sys
+import os
+
+import subprocess
+import queue
+from _thread import start_new_thread
+from time import sleep
+from platform import architecture
+
+import re
+import configparser
+from io import BytesIO
+import logging
+
+import urllib.request
+import supybot.ircmsgs as ircmsgs
+
+import random
+
+import tarfile
+from enum import IntEnum
+
+from gzip import GzipFile
+
+#
+# Helpers
+#
+
+class list(list):
+ """ Extended list """
+
+ def __lshift__(self, other):
+ self.append(other)
+ return self
+
+ def __rshift__(self, other):
+ if isinstance(other, list):
+ item = self.__getitem__(len(self) - 1)
+ try:
+ other.append(item)
+ self.pop()
+ except Exception:
+ raise TypeError("Cannot pass item to {}!".format(other)) from None
+
+ # Methods
+
+ def isEmpty(self):
+ return len(self) == 0
+
+class CmdResult(IntEnum):
+ UnknownCommand = -1
+ Success = 0
+ SyntaxFail = 1
+ RightsFail = 2
+ RuntimeError = 3
+
+class Updater(object):
+ parent = None
+ __current_revision = ""
+ lookuptable = {"64bit" : "amd64", "32bit" : "i386"}
+
+ def __init__(self, parent):
+ self.parent = parent
+ with open(os.path.join(self.parent.path, "snapshot.id"), "rb") as fobj:
+ self.__current_revision = fobj.read().decode("utf-8")
+ start_new_thread(self.checkForUpdates, ())
+
+ @property
+ def current_revision(self):
+ return self.__current_revision
+
+ @current_revision.setter
+ def current_revision(self, other):
+ self.__current_revision = other
+ if type(other) == str:
+ try:
+ other = other.encode("utf-8")
+ except Exception:
+ raise TypeError("Wrong datatype!") from None
+
+ with open(os.path.join(self.parent.path, "snapshot.id"), "wb") as fobj:
+ fobj.write(other)
+
+ def checkForUpdates(self):
+ while True:
+ try:
+ site = urllib.request.urlopen(self.parent.config["Addresses"]["snapshotList"]).read().decode("utf-8").split(self.parent.config["Updater"]["SplitString"])
+ site.remove(site[0])
+ site = [i.split("' title")[0] for i in site]
+
+ x = None
+ for i in site:
+ x = re.match(self.parent.config["RegExps"]["Snapshot"].format(sys.platform, self.lookuptable[architecture()[0]]), i)
+ if x:
+ rev = x.group(2)
+
+ if self.current_revision != rev:
+ self.current_revision = rev
+ self.loadNewSnapshot(x)
+
+ break
+ if not x:
+ self.parent.log.error("Updater: Update regular expression doesn't match!")
+
+ except Exception as e:
+ cerr << str(e) << endl
+ finally:
+ sleep(10)
+
+ def loadNewSnapshot(self, reg):
+ self.parent.log.info("Downloading snapshot with id {}".format(self.current_revision))
+ with open(os.path.join(self.parent.path, "snapshot"), "wb") as fobj:
+ fobj.write(urllib.request.urlopen(self.parent.config["Addresses"]["snapshotDownload"].format(reg.group(0).split("' title")[0])).read())
+
+ #extract the snapshot
+ tar = tarfile.open(os.path.join(self.parent.path, "snapshot"), mode="r:bz2")
+ tar.extractall(path=self.parent.path)
+ self.parent.log.info("New snapshot has been extracted.")
+
+ #get the openclonk-server autobuild
+ site = json.loads(urllib.request.urlopen(self.parent.config["Addresses"]["autobuildList"]).read().decode("utf-8"))
+
+ for commit in site:
+ for build in commit["builds"]:
+ if re.match(r"{}-{}-.*".format(sys.platform, self.lookuptable[architecture()[0]]), build["platform"]["triplet"]):
+ for b in build["components"]:
+ reg = re.match(self.parent.config["RegExps"]["Autobuild"], str(b["path"])) #skip the engine check as the only useful one is openclonk-server
+ if reg and (reg.group(1), reg.group(2), reg.group(3)) == (self.current_revision[:-3], sys.platform, self.lookuptable[architecture()[0]]):
+ self.parent.log.info("Downloading autobuild with id {}".format(self.current_revision))
+ buffer = BytesIO()
+ buffer.write(urllib.request.urlopen(self.parent.config["Addresses"]["autobuildDownload"].format(b["path"])).read())
+ buffer.seek(0)
+ with open(os.path.join(self.parent.path, self.parent.config["Updater"]["BinaryName"]), "wb") as fobj:
+ fobj.write(GzipFile(fileobj=buffer).read())
+
+ self.parent.log.info("New openclonk-server build has been extracted.")
+ os.chmod(os.path.join(self.parent.path, self.parent.config["Updater"]["BinaryName"]), os.stat(os.path.join(self.parent.path, self.parent.config["Updater"]["BinaryName"])).st_mode | 64)
+ return True
+
+
+
+class PyCRCtrl(object):
+ """Server control"""
+
+ clonk = None
+ thread_started = False
+ stopped = False
+ config = configparser.ConfigParser()
+
+ scenario = ""
+ commands = {}
+
+ path = None
+ config_path = None
+ scenlist = []
+ league_scenlist = []
+
+ topic = "Kein laufendes Spiel."
+
+ __state = "Lobby"
+ __ingamechat = "aktiviert"
+
+ updater = None
+ log = None
+ shutdowned = False
+
+ @property
+ def state(self):
+ return self.__state
+
+ @state.setter
+ def state(self, text):
+ if text in ["Lobby", "Lädt", "Läuft"]:
+ self.__state = text
+ self.setTopic("Aktuelles Szenario: {} | {}{} | Ingamechat ist {}.".format(self.scenario, self.state, (" | Liga" if self.scenario in self.league_scenlist else ""), self.ingamechat))
+
+ @property
+ def ingamechat(self):
+ return self.__ingamechat
+
+ @ingamechat.setter
+ def ingamechat(self, text):
+ if text in ["aktiviert", "deaktiviert"]:
+ self.__ingamechat = text
+ self.state = self.state
+
+ def __init__(self, irc, path, config="pycrctrl.ini"):
+ self.irc = irc
+ self.path = path
+ self.loadConfigFile(config)
+ self.setupLog()
+ self.ingamechat = "aktiviert" if self.config["Clonk"].getboolean("Autohost") else "deaktiviert"
+ self.loadScenarioList()
+
+ self.queue = queue.Queue(5)
+ if self.config["Updater"].getboolean("Enabled"):
+ self.updater = Updater(self)
+
+ def loadScenarioList(self) -> None:
+ if self.path == None:
+ raise OSError("No path specified")
+
+ with open(os.path.join(self.path,"scenarios.lst"), "r") as fobj:
+ self.scenlist = fobj.readlines()
+
+ with open(os.path.join(self.path, "scenarios_league.lst"), "r") as fobj:
+ self.league_scenlist = fobj.readlines()
+
+ self.log.debug("Scenario lists loaded.")
+
+ def setupLog(self) -> None:
+ self.log = logging.getLogger(type(self).__name__)
+ self.log.setLevel(getattr(logging, self.config["Logging"]["Level"], logging.INFO))
+
+ ch = logging.FileHandler(
+ os.path.join(self.path, self.config["Logging"]["File"])
+ )
+ ch.setLevel(getattr(logging, self.config["Logging"]["Level"], logging.INFO))
+ ch.setFormatter(logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s"))
+
+ self.log.addHandler(ch)
+ self.log.info("PyCRCtrl started.")
+
+ def loadConfigFile(self, config) -> None:
+ if self.path == None:
+ raise OSError("No path specified")
+
+ parser = configparser.ConfigParser()
+ self.config_path = conf = os.path.join(self.path, config)
+
+ if os.path.isdir(conf):
+ raise OSError("{} is a directory!".format(conf))
+
+ elif os.path.isfile(conf):
+ self.config.read(conf)
+
+ elif not os.path.exists(conf):
+ c = """[General]
+UseLogfile=true
+Prefix=@
+
+[Clonk]
+Engine=clonk
+Encoding=utf-8
+Commandline=/fullscreen /lobby:300 /config:config.txt /record /faircrew
+Autohost=false
+
+[IRC]
+Ingamechat=true
+
+ [Channels]
+ Parent="#clonk-SGGP"
+ Ingame="#clonk-SGGP-ingame"
+
+[Updater]
+Enabled=false
+ [Addresses]
+ snapshotList=http://openclonk.org/nightly-builds
+ snapshotDownload=http://openclonk.org/builds/nightly/snapshots/{}
+ autobuildList=https://autobuild.openclonk.org/api/v1/jobs
+ autobuildAddress=https://autobuild.openclonk.org/static/binaries/{}
+
+[RegExps]
+LobbyStart=((?:Los geht's!|Action go!)\\s*)
+Start=Start!
+PlayerJoin=^Client (.+) (?:verbunden|connected)\.\s*$
+PlayerLeave=^Client (.+) (?:entfernt|removed).*
+Shutdown=Spiel ausgewertet.*
+Autobuild=.*/openclonk-server-(.*)-(.*)-(.*)-.*
+Snapshot=openclonk-snapshot-(.*)-(.*)-{}-{}-."""
+
+ self.config.read_string(c)
+ with open(conf, "w") as fobj:
+ self.config.write(fobj)
+
+ def startClonk(self):
+ try:
+ while True:
+ if self.scenario == "":
+ if self.queue.empty() == False:
+ self.scenario = self.queue.get()
+ else:
+ self.scenario = random.choice(self.scenlist).splitlines()[0]
+
+ self.clonk = subprocess.Popen(
+ './{} {} "{}"'.format(self.config["Clonk"]["Engine"], self.config["Clonk"]["Commandline"] + " " + self.config["Clonk"]["commandlinePrefix"] + ("league" if self.scenario in self.league_scenlist else "noleague"), self.scenario),
+ 0,
+ None,
+ subprocess.PIPE,
+ subprocess.PIPE,
+ subprocess.STDOUT,
+ shell=True,
+ cwd=self.path,
+ encoding=self.config["Clonk"]["Encoding"]
+ )
+ self.state = "Lobby"
+ self.readServerOutput()
+ if self.config["Clonk"].getboolean("Autohost") == False:
+ self.thread_started = False
+ self.setTopic("Kein laufendes Spiel.")
+ break
+
+ finally:
+ if self.clonk:
+ self.clonk.stdin.close()
+
+ return (CmdResult.Success, "")
+
+ def readServerOutput(self):
+ while True:
+ try:
+ output = self.clonk.stdout.readline()
+
+ if re.match(self.config["RegExps"]["Shutdown"], output):
+ self.clonk.stdin.close()
+ elif output == "" and self.clonk.poll() is not None:
+ if self.clonk:
+ self.clonk.stdin.close()
+ self.clonk = None
+ self.scenario = ""
+ return
+
+ elif output:
+ output = output.splitlines()[0]
+ #output = output.decode("utf-8").splitlines()[0]
+ output = output[(output.find("] ") if output.find("] ") != -1 else -2)+len("] "):]
+
+ if output[0] == ">":
+ output = output[1:]
+
+ part = self.isMessage(output)
+ if part and part.group(0) != self.irc.nick:
+ self.log.info(output)
+ cmd = part.group(3).split(" ", 1)
+ found = False
+ x = None
+ if len(cmd) > 0:
+ for key in self.commands.keys():
+ if key == cmd[0].splitlines()[0]:
+ found = True
+ try:
+ x = self.commands[key](cmd[1].split(" "), user=part.group(1))
+ except IndexError:
+ x = self.commands[key](user=part.group(1))
+ break
+
+ if not found:
+ self.writeToServer('Unbekannter Befehl: "' + part.group(2) + '"!')
+ if x:
+ if type(x) == tuple and x[1] != "":
+ self.writeToServer(x[1])
+ del x
+ if re.match(self.config["RegExps"]["LobbyStart"], output):
+ self.state = "Lädt"
+
+ elif re.match(self.config["RegExps"]["Start"], output):
+ self.state = "Läuft"
+
+ if self.irc and self.ingamechat == "aktiviert":
+ if output.find("<" + self.irc.nick + ">") == -1:
+ if self.isMessage(output) and output.find("[IRC]") == -1 and output.find(self.config["General"]["Prefix"]) == -1:
+ self.irc.reply("[Clonk]{}".format(output), to=self.config["Channels"]["Ingame"])
+
+ elif any((re.match(self.config["RegExps"]["PlayerJoin"], output), re.match(self.config["RegExps"]["PlayerLeave"], output))):
+ self.irc.reply(output, to=self.config["Channels"]["Ingame"])
+
+
+ except KeyboardInterrupt:
+ if self.clonk.stdin:
+ self.clonk.stdin.close()
+
+ except Exception as e:
+ self.log.exception(e.args[0])
+ continue
+
+ return (CmdResult.Success, "")
+
+ def doPrivmsg(self, msg):
+ if not (self.irc and self.ingamechat == "aktiviert"):
+ return
+ for channel in msg.args[0].split(","):
+ if channel == self.config["Channels"]["Ingame"] and msg.nick != self.irc.nick:
+ self.writeToServer("[IRC]<{}> {}".format(msg.nick, msg.args[1]))
+
+ #
+ # Commands
+ #
+
+ def host(self, scenario=None, user=None) -> str:
+ if not scenario:
+ return (CmdResult.SyntaxFail, "Bitte gib einen Szenarionamen an!")
+ if hasattr(scenario, "decode"):
+ try:
+ scenario = scenario.decode(self.config["Clonk"]["Encoding"], "replace")
+ except:
+ self.log.warning("Unable to decode {}".format(scenario))
+ return (CmdResult.RuntimeError, "Dekodierfehler. Bitte kontaktiere den Hoster dieses Servers.")
+
+ scenario = scenario.splitlines()[0]
+ if scenario == "random":
+ scenario = random.choice(self.scenlist).splitlines()[0]
+
+ elif scenario not in self.scenlist:
+ return (CmdResult.SyntaxFail,'Szenario "{}" wurde nicht gefunden!'.format(scenario))
+
+ if self.thread_started == False:
+ self.scenario = scenario
+ self.thread_started = True
+ start_new_thread(self.startClonk,())
+ return (CmdResult.Success, 'Szenario "{}" wird jetzt gehostet.'.format(scenario))
+
+ if not self.queue.full():
+ self.queue.put(scenario)
+ return (CmdResult.Success, 'Szenario "{}" wurde der Warteschlange hinzugefügt.'.format(scenario))
+ else:
+ return (CmdResult.RuntimeError, "Die Warteschlange ist voll!")
+
+ def start(self, time=None, user=""):
+ """Startet das Spiel."""
+ self.stopped = False
+ if time:
+ try:
+ time = int(time[0])
+ except:
+ time = 5
+ self.writeToServer("/start {}".format(time))
+ else:
+ self.writeToServer("/start")
+
+ return (CmdResult.Success, "")
+
+
+ def stop(self, prm=None, user=""):
+ """Stoppt den Countdown."""
+ def stopping(self):
+ while self.clonk and self.stopped:
+ self.writeToServer("/start 60000")
+ if self.stopped == False:
+ return
+ sleep(100)
+
+ if self.stopped == False:
+ self.stopped = True
+ start_new_thread(stopping, (self,))
+
+ return (CmdResult.Success, "")
+
+
+ def help(self, prm=None, user=""):
+ """Gibt die Hilfe aus."""
+ self.writeToServer("Verfügbare Befehle:")
+ for text, function in self.commands.items():
+ self.writeToServer("{} -- {}".format(text, function.__doc__))
+
+ return (CmdResult.Success, "")
+
+
+ def displayQueue(self, prm=None, user=""):
+ """Gibt die Warteschlange aus."""
+ self.writeToServer("Warteschlange:")
+
+ for i,scen in enumerate(self.queue.queue):
+ self.writeToServer("{}. {}".format(i+1, scen))
+
+ return (CmdResult.Success, "")
+
+
+ def list(self, prm=None, user=""):
+ """Zeigt die Szenarioliste an."""
+ self.writeToServer("Szenarienlist:\n-------------")
+ for scen in self.scenlist:
+ self.writeToServer(scen + ("(Liga)" if scen in self.league_scenlist else ""))
+
+ return (CmdResult.Success, "")
+
+ def ircCommands(self, prm=None, user=""):
+ """Enthält Befehle zur Steuerung der IRC-Funktionen."""
+ if not prm:
+ return (CmdResult.SyntaxFail, "")
+
+ if prm[0] == "ingamechat":
+ if prm[1] == "off":
+ self.ingamechat = "deaktiviert"
+ elif prm[1] == "on":
+ self.ingamechat = "aktiviert"
+
+ return (CmdResult.Success, "")
+
+ #
+ # Methods
+ #
+
+ def isMessage(self, msg):
+ return re.match(self.config["RegExps"]["Message"].format(prefix=self.config["General"]["Prefix"]), msg)
+
+ def getMessageNick(self, msg):
+ m = self.isMessage(msg)
+ if m:
+ return m.group(1)
+
+ def addCommand(self, function, text):
+ self.commands[text.split(" ")[0]] = function
+ return self
+
+ def addScenario(self, link):
+ name = ""
+ for item in link.split("/"):
+ if re.match(r"(.*)\.[oc][c4]s",item):
+ name = item
+ break
+
+ site = urllib.request.urlopen(link).read() #WARNING: Raises an error if the link is invalid!
+ with open(os.path.join(self.path, name),"wb") as fobj:
+ fobj.write(site)
+
+ try:
+ self.scenlist.index(name)
+ except Exception:
+ self.scenlist.append(name)
+ return self
+
+ def writeToServer(self, text=None) -> tuple:
+ if text == None and self.clonk == None:
+ return (CmdResult.RuntimeError, "")
+ elif type(text) != str:
+ try:
+ text = text.decode(self.config["Clonk"]["Encoding"])
+ except:
+ raise IOError("Cannot write anything else to the server except the following data types: bytes, str (got {})".format(type(text).__name__))
+
+ if self.clonk and self.clonk.stdin:
+ self.clonk.stdin.write(text + "\n")
+ self.clonk.stdin.flush()
+ return (CmdResult.Success, "")
+
+ def setTopic(self, text=None) -> None:
+ if not self.irc:
+ return
+
+ if type(text) != str:
+ try:
+ text = text.decode(self.config["Clonk"]["Encoding"])
+ except:
+ raise TypeError("bytes or str expected, got {}".format(type(text).__name__))
+
+ if self.topic != text:
+ self.topic = text
+ channel = self.config["Channels"]["Ingame"]
+ if not channel.startswith("#"):
+ raise ValueError("Invalid channel: {} (ident: {})".format(channel, channel == "#clonk-SGGP-ingame"))
+ self.irc.sendMsg(ircmsgs.topic(self.config["Channels"]["Ingame"], text))
+
+ def shutdown(self):
+ if self.shutdowned:
+ return
+
+ self.log.info("Shutting down...")
+
+ del self.updater
+
+ if self.clonk and self.clonk.stdin and not self.clonk.stdin.closed:
+ self.clonk.stdin.close()
+
+ with open(self.config_path, "w") as fobj:
+ self.config.write(fobj)
+ self.log.debug("Config file saved.")
+
+ with open(os.path.join(self.path, "scenarios.lst"), "w") as fobj:
+ fobj.writelines(self.scenlist)
+
+ with open(os.path.join(self.path, "scenarios_league.lst"), "w") as fobj:
+ fobj.writelines(self.league_scenlist)
+
+ self.log.debug("Scenario lists saved.")
+ self.setTopic("Kein laufendes Spiel.")
+ logging.shutdown()
+ self.shutdowned = True