commit 02cce55db13353d9153705b6fdc688a12abaa385 Author: BotoX Date: Wed Aug 2 23:41:02 2017 +0200 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..70ca3b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fd3c22 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Torchlight3 + +## 0. Requirements + * Python3.6 + * FFMPEG + * youtube-dl + * On game server: + * custom sourcemod + * sm-ext-AsyncSocket extension + * smjansson extension + * SMJSONAPI plugin + * sm-ext-Voice extension + +## 1. Install + * Install python3 and python-virtualenv + * Create a virtualenv: `virtualenv venv` + * Activate the virtualenv: `. venv/bin/activate` + * Install all dependencies: `pip install -r requirements.txt` + +## 2. Usage +Set up game server stuff. +Adapt config.json. + +##### Make sure you are in the virtualenv! (`. venv/bin/activate`) +Run: `python main.py` diff --git a/Torchlight/AccessManager.py b/Torchlight/AccessManager.py new file mode 100644 index 0000000..55622ec --- /dev/null +++ b/Torchlight/AccessManager.py @@ -0,0 +1,43 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import json +from collections import OrderedDict + +class AccessManager(): + ACCESS_FILE = "access.json" + def __init__(self): + self.Logger = logging.getLogger(__class__.__name__) + self.AccessDict = OrderedDict() + + def Load(self): + self.Logger.info("Loading access from {0}".format(self.ACCESS_FILE)) + + with open(self.ACCESS_FILE, "r") as fp: + self.AccessDict = json.load(fp, object_pairs_hook = OrderedDict) + + def Save(self): + self.Logger.info("Saving access to {0}".format(self.ACCESS_FILE)) + + self.AccessDict = OrderedDict( + sorted(self.AccessDict.items(), key = lambda x: x[1]["level"], reverse = True)) + + with open(self.ACCESS_FILE, "w") as fp: + json.dump(self.AccessDict, fp, indent = '\t') + + def __len__(self): + return len(self.AccessDict) + + def __getitem__(self, key): + if key in self.AccessDict: + return self.AccessDict[key] + + def __setitem__(self, key, value): + self.AccessDict[key] = value + + def __delitem__(self, key): + if key in self.AccessDict: + del self.AccessDict[key] + + def __iter__(self): + return self.AccessDict.items().__iter__() diff --git a/Torchlight/AsyncClient.py b/Torchlight/AsyncClient.py new file mode 100644 index 0000000..ae736e1 --- /dev/null +++ b/Torchlight/AsyncClient.py @@ -0,0 +1,97 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import asyncio +import logging +import json + +class ClientProtocol(asyncio.Protocol): + def __init__(self, loop, master): + self.Loop = loop + self.Master = master + self.Transport = None + self.Buffer = bytearray() + + def connection_made(self, transport): + self.Transport = transport + + def data_received(self, data): + self.Buffer += data + + chunks = self.Buffer.split(b'\0') + if data[-1] == b'\0': + chunks = chunks[:-1] + self.Buffer = bytearray() + else: + self.Buffer = bytearray(chunks[-1]) + chunks = chunks[:-1] + + for chunk in chunks: + self.Master.OnReceive(chunk) + + def connection_lost(self, exc): + self.Transport.close() + self.Transport = None + self.Master.OnDisconnect(exc) + + def Send(self, data): + if self.Transport: + self.Transport.write(data) + +class AsyncClient(): + def __init__(self, loop, host, port, master): + self.Logger = logging.getLogger(__class__.__name__) + self.Loop = loop + self.Host = host + self.Port = port + self.Master = master + + self.Protocol = None + self.SendLock = asyncio.Lock() + self.RecvFuture = None + + async def Connect(self): + while True: + self.Logger.warn("Connecting...") + try: + _, self.Protocol = await self.Loop.create_connection( + lambda: ClientProtocol(self.Loop, self), host = self.Host, port = self.Port) + break + except: + await asyncio.sleep(1.0) + + def OnReceive(self, data): + Obj = json.loads(data) + + if "method" in Obj and Obj["method"] == "publish": + self.Master.OnPublish(Obj) + else: + if self.RecvFuture: + self.RecvFuture.set_result(Obj) + + def OnDisconnect(self, exc): + self.Protocol = None + if self.RecvFuture: + self.RecvFuture.cancel() + self.Master.OnDisconnect(exc) + + async def Send(self, obj): + if not self.Protocol: + return None + + Data = json.dumps(obj, ensure_ascii = False, separators = (',', ':')).encode("UTF-8") + + with (await self.SendLock): + if not self.Protocol: + return None + + self.RecvFuture = asyncio.Future() + self.Protocol.Send(Data) + await self.RecvFuture + + if self.RecvFuture.done(): + Obj = self.RecvFuture.result() + else: + Obj = None + + self.RecvFuture = None + return Obj diff --git a/Torchlight/AudioManager.py b/Torchlight/AudioManager.py new file mode 100644 index 0000000..7e182d8 --- /dev/null +++ b/Torchlight/AudioManager.py @@ -0,0 +1,271 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import sys +import io +import math +from .FFmpegAudioPlayer import FFmpegAudioPlayerFactory + +class AudioPlayerFactory(): + AUDIOPLAYER_FFMPEG = 1 + + def __init__(self, master): + self.Logger = logging.getLogger(__class__.__name__) + self.Master = master + self.Torchlight = self.Master.Torchlight + + self.FFmpegAudioPlayerFactory = FFmpegAudioPlayerFactory(self) + + def __del__(self): + self.Logger.info("~AudioPlayerFactory()") + + def NewPlayer(self, _type): + if _type == self.AUDIOPLAYER_FFMPEG: + return self.FFmpegAudioPlayerFactory.NewPlayer() + +class AntiSpam(): + def __init__(self, master): + self.Logger = logging.getLogger(__class__.__name__) + self.Master = master + self.Torchlight = self.Master.Torchlight + + self.LastClips = dict() + self.DisabledTime = None + + def CheckAntiSpam(self, player): + if self.DisabledTime and self.DisabledTime > self.Torchlight().Master.Loop.time() and \ + not (player.Access and player.Access["level"] >= self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]): + + self.Torchlight().SayPrivate(player, "Torchlight is currently on cooldown! ({0} seconds left)".format( + math.ceil(self.DisabledTime - self.Torchlight().Master.Loop.time()))) + return False + + return True + + def RegisterClip(self, clip): + self.LastClips[hash(clip)] = dict({"timestamp": None, "duration": 0.0, "dominant": False, "active": True}) + + def SpamCheck(self): + Now = self.Torchlight().Master.Loop.time() + Duration = 0.0 + + for Key, Clip in list(self.LastClips.items()): + if not Clip["timestamp"]: + continue + + if Clip["timestamp"] + self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"] < Now: + if not Clip["active"]: + del self.LastClips[Key] + continue + + Duration += Clip["duration"] + + if Duration > self.Torchlight().Config["AntiSpam"]["MaxUsageTime"]: + self.DisabledTime = self.Torchlight().Master.Loop.time() + self.Torchlight().Config["AntiSpam"]["PunishDelay"] + self.Torchlight().SayChat("Blocked voice commands for the next {0} seconds. Used {1} seconds within {2} seconds.".format( + self.Torchlight().Config["AntiSpam"]["PunishDelay"], self.Torchlight().Config["AntiSpam"]["MaxUsageTime"], self.Torchlight().Config["AntiSpam"]["MaxUsageSpan"])) + + # Make a copy of the list since AudioClip.Stop() will change the list + for AudioClip in self.Master.AudioClips[:]: + if AudioClip.Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]: + AudioClip.Stop() + + self.LastClips.clear() + + def OnPlay(self, clip): + self.LastClips[hash(clip)]["timestamp"] = self.Torchlight().Master.Loop.time() + + HasDominant = False + for Key, Clip in self.LastClips.items(): + if Clip["dominant"]: + HasDominant = True + break + + self.LastClips[hash(clip)]["dominant"] = not HasDominant + + def OnStop(self, clip): + self.LastClips[hash(clip)]["active"] = False + + if self.LastClips[hash(clip)]["dominant"]: + for Key, Clip in self.LastClips.items(): + if Clip["active"]: + Clip["dominant"] = True + break + + self.LastClips[hash(clip)]["dominant"] = False + + def OnUpdate(self, clip, old_position, new_position): + Delta = new_position - old_position + Clip = self.LastClips[hash(clip)] + + if not Clip["dominant"]: + return + + Clip["duration"] += Delta + self.SpamCheck() + + +class AudioManager(): + def __init__(self, torchlight): + self.Logger = logging.getLogger(__class__.__name__) + self.Torchlight = torchlight + self.AntiSpam = AntiSpam(self) + self.AudioPlayerFactory = AudioPlayerFactory(self) + self.AudioClips = [] + + def __del__(self): + self.Logger.info("~AudioManager()") + + def CheckLimits(self, player): + Level = 0 + if player.Access: + Level = player.Access["level"] + + if str(Level) in self.Torchlight().Config["AudioLimits"]: + if self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"] >= 0 and \ + player.Storage["Audio"]["Uses"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"]: + + self.Torchlight().SayPrivate(player, "You have used up all of your free uses! ({0} uses)".format( + self.Torchlight().Config["AudioLimits"][str(Level)]["Uses"])) + return False + + if player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"]: + self.Torchlight().SayPrivate(player, "You have used up all of your free time! ({0} seconds)".format( + self.Torchlight().Config["AudioLimits"][str(Level)]["TotalTime"])) + return False + + TimeElapsed = self.Torchlight().Master.Loop.time() - player.Storage["Audio"]["LastUse"] + UseDelay = player.Storage["Audio"]["LastUseLength"] * self.Torchlight().Config["AudioLimits"][str(Level)]["DelayFactor"] + + if TimeElapsed < UseDelay: + self.Torchlight().SayPrivate(player, "You are currently on cooldown! ({0} seconds left)".format( + round(UseDelay - TimeElapsed))) + return False + + return True + + def Stop(self, player, extra): + Level = 0 + if player.Access: + Level = player.Access["level"] + + for AudioClip in self.AudioClips[:]: + if extra and not extra.lower() in AudioClip.Player.Name.lower(): + continue + + if not Level or Level < AudioClip.Level: + AudioClip.Stops.add(player.UserID) + + if len(AudioClip.Stops) >= 3: + AudioClip.Stop() + self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.") + if player != AudioClip.Player: + self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID)) + else: + self.Torchlight().SayPrivate(player, "This audio clip needs {0} more !stop's.".format(3 - len(AudioClip.Stops))) + else: + AudioClip.Stop() + self.Torchlight().SayPrivate(AudioClip.Player, "Your audio clip was stopped.") + if player != AudioClip.Player: + self.Torchlight().SayPrivate(player, "Stopped \"{0}\"({1}) audio clip.".format(AudioClip.Player.Name, AudioClip.Player.UserID)) + + def AudioClip(self, player, uri, _type = AudioPlayerFactory.AUDIOPLAYER_FFMPEG): + Level = 0 + if player.Access: + Level = player.Access["level"] + + if self.Torchlight().Disabled and self.Torchlight().Disabled > Level: + self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!") + return None + + if not self.AntiSpam.CheckAntiSpam(player): + return None + + if not self.CheckLimits(player): + return None + + Clip = AudioClip(self, player, uri, _type) + self.AudioClips.append(Clip) + + if not player.Access or player.Access["level"] < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]: + self.AntiSpam.RegisterClip(Clip) + Clip.AudioPlayer.AddCallback("Play", lambda *args: self.AntiSpam.OnPlay(Clip, *args)) + Clip.AudioPlayer.AddCallback("Stop", lambda *args: self.AntiSpam.OnStop(Clip, *args)) + Clip.AudioPlayer.AddCallback("Update", lambda *args: self.AntiSpam.OnUpdate(Clip, *args)) + + return Clip + + def OnDisconnect(self, player): + for AudioClip in self.AudioClips[:]: + if AudioClip.Player == player: + AudioClip.Stop() + + +class AudioClip(): + def __init__(self, master, player, uri, _type): + self.Logger = logging.getLogger(__class__.__name__) + self.Master = master + self.Torchlight = self.Master.Torchlight + self.Player = player + self.Type = _type + self.URI = uri + self.LastPosition = None + self.Stops = set() + + self.Level = 0 + if self.Player.Access: + self.Level = self.Player.Access["level"] + + self.AudioPlayer = self.Master.AudioPlayerFactory.NewPlayer(self.Type) + self.AudioPlayer.AddCallback("Play", self.OnPlay) + self.AudioPlayer.AddCallback("Stop", self.OnStop) + self.AudioPlayer.AddCallback("Update", self.OnUpdate) + + def __del__(self): + self.Logger.info("~AudioClip()") + + def Play(self, seconds = None): + return self.AudioPlayer.PlayURI(self.URI, seconds) + + def Stop(self): + return self.AudioPlayer.Stop() + + def OnPlay(self): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI) + + self.Player.Storage["Audio"]["Uses"] += 1 + self.Player.Storage["Audio"]["LastUse"] = self.Torchlight().Master.Loop.time() + self.Player.Storage["Audio"]["LastUseLength"] = 0.0 + + def OnStop(self): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + self.URI) + self.Master.AudioClips.remove(self) + + if self.AudioPlayer.Playing: + Delta = self.AudioPlayer.Position - self.LastPosition + self.Player.Storage["Audio"]["TimeUsed"] += Delta + self.Player.Storage["Audio"]["LastUseLength"] += Delta + + if str(self.Level) in self.Torchlight().Config["AudioLimits"]: + if self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"]: + self.Torchlight().SayPrivate(self.Player, "You have used up all of your free time! ({0} seconds)".format( + self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"])) + elif self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]: + self.Torchlight().SayPrivate(self.Player, "Your audio clip exceeded the maximum length! ({0} seconds)".format( + self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"])) + + del self.AudioPlayer + + def OnUpdate(self, old_position, new_position): + Delta = new_position - old_position + self.LastPosition = new_position + + self.Player.Storage["Audio"]["TimeUsed"] += Delta + self.Player.Storage["Audio"]["LastUseLength"] += Delta + + if not str(self.Level) in self.Torchlight().Config["AudioLimits"]: + return + + if (self.Player.Storage["Audio"]["TimeUsed"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["TotalTime"] or + self.Player.Storage["Audio"]["LastUseLength"] >= self.Torchlight().Config["AudioLimits"][str(self.Level)]["MaxLength"]): + self.Stop() diff --git a/Torchlight/CommandHandler.py b/Torchlight/CommandHandler.py new file mode 100644 index 0000000..ee6fd03 --- /dev/null +++ b/Torchlight/CommandHandler.py @@ -0,0 +1,111 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import asyncio +import sys +import re +import traceback +import math +from importlib import reload +from . import Commands + +class CommandHandler(): + def __init__(self, Torchlight): + self.Logger = logging.getLogger(__class__.__name__) + self.Torchlight = Torchlight + self.Commands = [] + self.NeedsReload = False + + def Setup(self): + Counter = len(self.Commands) + self.Commands.clear() + if Counter: + self.Logger.info(sys._getframe().f_code.co_name + " Unloaded {0} commands!".format(Counter)) + + Counter = 0 + for subklass in sorted(Commands.BaseCommand.__subclasses__(), key = lambda x: x.Order, reverse = True): + try: + Command = subklass(self.Torchlight) + if hasattr(Command, "_setup"): + Command._setup() + except Exception as e: + self.Logger.error(traceback.format_exc()) + else: + self.Commands.append(Command) + Counter += 1 + + self.Logger.info(sys._getframe().f_code.co_name + " Loaded {0} commands!".format(Counter)) + + def Reload(self): + try: + reload(Commands) + except Exception as e: + self.Logger.error(traceback.format_exc()) + else: + self.Setup() + + async def HandleCommand(self, line, player): + Message = line.split(sep = ' ', maxsplit = 1) + if len(Message) < 2: + Message.append("") + Message[1] = Message[1].strip() + + Level = 0 + if player.Access: + Level = player.Access["level"] + + RetMessage = None + Ret = None + for Command in self.Commands: + for Trigger in Command.Triggers: + Match = False + RMatch = None + if isinstance(Trigger, tuple): + if Message[0].lower().startswith(Trigger[0], 0, Trigger[1]): + Match = True + elif isinstance(Trigger, str): + if Message[0].lower() == Trigger.lower(): + Match = True + else: # compiled regex + RMatch = Trigger.search(line) + if RMatch: + Match = True + + if not Match: + continue + + self.Logger.debug(sys._getframe().f_code.co_name + " \"{0}\" Match -> {1} | {2}".format(player.Name, Command.__class__.__name__, Trigger)) + + if Level < Command.Level: + RetMessage = "You do not have access to this command! (You: {0} | Required: {1})".format(Level, Command.Level) + continue + + try: + if RMatch: + Ret = await Command._rfunc(line, RMatch, player) + else: + Ret = await Command._func(Message, player) + except Exception as e: + self.Logger.error(traceback.format_exc()) + self.Torchlight().SayChat("Error: {0}".format(str(e))) + + RetMessage = None + + if isinstance(Ret, str): + Message = Ret.split(sep = ' ', maxsplit = 1) + Ret = None + + if Ret != None and Ret > 0: + break + + if Ret != None and Ret >= 0: + break + + if RetMessage: + self.Torchlight().SayPrivate(player, RetMessage) + + if self.NeedsReload: + self.NeedsReload = False + self.Reload() + + return Ret diff --git a/Torchlight/Commands.py b/Torchlight/Commands.py new file mode 100644 index 0000000..4cdc144 --- /dev/null +++ b/Torchlight/Commands.py @@ -0,0 +1,730 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import asyncio +import os +import sys +import logging +import math +from .Utils import Utils, DataHolder +import traceback + +class BaseCommand(): + Order = 0 + def __init__(self, torchlight): + self.Logger = logging.getLogger(__class__.__name__) + self.Torchlight = torchlight + self.Triggers = [] + self.Level = 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name) + +### FILTER COMMANDS ### +class URLFilter(BaseCommand): + Order = 1 + import re + import aiohttp + import magic + import datetime + import json + import io + from bs4 import BeautifulSoup + from PIL import Image + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = [self.re.compile(r'''(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))''', self.re.IGNORECASE)] + self.Level = -1 + self.re_youtube = self.re.compile(r'.*?(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11}).*?') + + async def URLInfo(self, url, yt = False): + Info = None + match = self.re_youtube.search(url) + if match or yt: + Temp = DataHolder() + Time = None + + if Temp(url.find("&t=")) != -1 or Temp(url.find("?t=")) != -1 or Temp(url.find("#t=")) != -1: + TimeStr = url[Temp.value + 3:].split('&')[0].split('?')[0].split('#')[0] + if TimeStr: + Time = Utils.ParseTime(TimeStr) + + Proc = await asyncio.create_subprocess_exec("youtube-dl", "--dump-json", "-xg", url, + stdout = asyncio.subprocess.PIPE) + Out, _ = await Proc.communicate() + + url, Info = Out.split(b'\n', maxsplit = 1) + url = url.strip().decode("ascii") + Info = self.json.loads(Info) + + if Info["extractor_key"] == "Youtube": + self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2}/5.00 | {3:,}".format( + Info["title"], str(self.datetime.timedelta(seconds = Info["duration"])), round(Info["average_rating"], 2), int(Info["view_count"]))) + else: + match = None + + url += "#t={0}".format(Time) + + else: + try: + async with self.aiohttp.ClientSession() as session: + Response = await asyncio.wait_for(session.get(url), 5) + if Response: + ContentType = Response.headers.get("Content-Type") + ContentLength = Response.headers.get("Content-Length") + Content = await asyncio.wait_for(Response.content.read(65536), 5) + + if not ContentLength: + ContentLength = -1 + + if ContentType.startswith("text") and not ContentType.startswith("text/plain"): + Soup = self.BeautifulSoup(Content.decode("utf-8", errors = "ignore"), "lxml") + if Soup.title: + self.Torchlight().SayChat("[URL] {0}".format(Soup.title.string)) + elif ContentType.startswith("image"): + fp = self.io.BytesIO(Content) + im = self.Image.open(fp) + self.Torchlight().SayChat("[IMAGE] {0} | Width: {1} | Height: {2} | Size: {3}".format(im.format, im.size[0], im.size[1], Utils.HumanSize(ContentLength))) + fp.close() + else: + Filetype = self.magic.from_buffer(bytes(Content)) + self.Torchlight().SayChat("[FILE] {0} | Size: {1}".format(Filetype, Utils.HumanSize(ContentLength))) + + Response.close() + except Exception as e: + self.Torchlight().SayChat("Error: {0}".format(str(e))) + self.Logger.error(traceback.format_exc()) + + self.Torchlight().LastUrl = url + return url + + async def _rfunc(self, line, match, player): + Url = match.groups()[0] + if not Url.startswith("http") and not Url.startswith("ftp"): + Url = "http://" + Url + + if line.startswith("!yt "): + URL = await self.URLInfo(Url, True) + return "!yt " + URL + + asyncio.ensure_future(self.URLInfo(Url)) + return -1 + +### FILTER COMMANDS ### + +### LEVEL 0 COMMANDS ### +class Access(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!access", "!who", "!whois"] + self.Level = 0 + + def FormatAccess(self, player): + Answer = "#{0} \"{1}\"({2}) is ".format(player.UserID, player.Name, player.UniqueID) + Level = str(0) + if player.Access: + Level = str(player.Access["level"]) + Answer += "level {0!s} as {1}.".format(Level, player.Access["name"]) + else: + Answer += "not authenticated." + + if Level in self.Torchlight().Config["AudioLimits"]: + Uses = self.Torchlight().Config["AudioLimits"][Level]["Uses"] + TotalTime = self.Torchlight().Config["AudioLimits"][Level]["TotalTime"] + + if Uses >= 0: + Answer += " Uses: {0}/{1}".format(player.Storage["Audio"]["Uses"], Uses) + if TotalTime >= 0: + Answer += " Time: {0}/{1}".format(round(player.Storage["Audio"]["TimeUsed"], 2), round(TotalTime, 2)) + + return Answer + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + Count = 0 + if message[0] == "!access": + if message[1]: + return -1 + + self.Torchlight().SayChat(self.FormatAccess(player)) + + elif message[0] == "!who": + for Player in self.Torchlight().Players: + if Player.Name.lower().find(message[1].lower()) != -1: + self.Torchlight().SayChat(self.FormatAccess(Player)) + + Count += 1 + if Count >= 3: + break + + elif message[0] == "!whois": + for UniqueID, Access in self.Torchlight().Access: + if Access["name"].lower().find(message[1].lower()) != -1: + Player = self.Torchlight().Players.FindUniqueID(UniqueID) + if Player: + self.Torchlight().SayChat(self.FormatAccess(Player)) + else: + self.Torchlight().SayChat("#? \"{0}\"({1}) is level {2!s} is currently offline.".format(Access["name"], UniqueID, Access["level"])) + + Count += 1 + if Count >= 3: + break + return 0 + +class Calculate(BaseCommand): + import urllib.parse + import aiohttp + import json + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!c"] + self.Level = 0 + + async def Calculate(self, Params): + async with self.aiohttp.ClientSession() as session: + Response = await asyncio.wait_for(session.get("http://math.leftforliving.com/query", params=Params), 5) + if not Response: + return 1 + + Data = await asyncio.wait_for(Response.json(content_type = "text/json"), 5) + if not Data: + return 2 + + if not Data["error"]: + self.Torchlight().SayChat(Data["answer"]) + return 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + Params = dict({"question": message[1]}) + Ret = await self.Calculate(Params) + return Ret + +class WolframAlpha(BaseCommand): + import urllib.parse + import aiohttp + import xml.etree.ElementTree as etree + import re + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!cc"] + self.Level = 0 + + def Clean(self, Text): + return self.re.sub("[ ]{2,}", " ", Text.replace(' | ', ': ').replace('\n', ' | ').replace('~~', ' ≈ ')).strip() + + async def Calculate(self, Params): + async with self.aiohttp.ClientSession() as session: + Response = await asyncio.wait_for(session.get("http://api.wolframalpha.com/v2/query", params=Params), 10) + if not Response: + return 1 + + Data = await asyncio.wait_for(Response.text(), 5) + if not Data: + return 2 + + Root = self.etree.fromstring(Data) + + + # Find all pods with plaintext answers + # Filter out None -answers, strip strings and filter out the empty ones + Pods = list(filter(None, [p.text.strip() for p in Root.findall('.//subpod/plaintext') if p is not None and p.text is not None])) + + # no answer pods found, check if there are didyoumeans-elements + if not Pods: + Didyoumeans = Root.find("didyoumeans") + # no support for future stuff yet, TODO? + if not Didyoumeans: + # If there's no pods, the question clearly wasn't understood + self.Torchlight().SayChat("Sorry, couldn't understand the question.") + return 3 + + Options = [] + for Didyoumean in Didyoumeans: + Options.append("\"{0}\"".format(Didyoumean.text)) + Line = " or ".join(Options) + Line = "Did you mean {0}?".format(Line) + self.Torchlight().SayChat(Line) + return 0 + + # If there's only one pod with text, it's probably the answer + # example: "integral x²" + if len(Pods) == 1: + Answer = self.Clean(Pods[0]) + self.Torchlight().SayChat(Answer) + return 0 + + # If there's multiple pods, first is the question interpretation + Question = self.Clean(Pods[0].replace(' | ', ' ').replace('\n', ' ')) + # and second is the best answer + Answer = self.Clean(Pods[1]) + self.Torchlight().SayChat("{0} = {1}".format(Question, Answer)) + return 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + Params = dict({"input": message[1], "appid": self.Torchlight().Config["WolframAPIKey"]}) + Ret = await self.Calculate(Params) + return Ret + +class WUnderground(BaseCommand): + import aiohttp + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!w"] + self.Level = 0 + + async def _func(self, message, player): + if not message[1]: + # Use IP address + Search = "autoip" + Additional = "?geo_ip={0}".format(player.Address.split(":")[0]) + else: + async with self.aiohttp.ClientSession() as session: + Response = await asyncio.wait_for(session.get("http://autocomplete.wunderground.com/aq?format=JSON&query={0}".format(message[1])), 5) + if not Response: + return 2 + + Data = await asyncio.wait_for(Response.json(), 5) + if not Data: + return 3 + + if not Data["RESULTS"]: + self.Torchlight().SayPrivate(player, "[WU] No cities match your search query.") + return 4 + + Search = Data["RESULTS"][0]["name"] + Additional = "" + + async with self.aiohttp.ClientSession() as session: + Response = await asyncio.wait_for(session.get("http://api.wunderground.com/api/{0}/conditions/q/{1}.json{2}".format( + self.Torchlight().Config["WundergroundAPIKey"], Search, Additional)), 5) + if not Response: + return 2 + + Data = await asyncio.wait_for(Response.json(), 5) + if not Data: + return 3 + + if "error" in Data["response"]: + self.Torchlight().SayPrivate(player, "[WU] {0}.".format(Data["response"]["error"]["description"])) + return 5 + + if not "current_observation" in Data: + Choices = str() + NumResults = len(Data["response"]["results"]) + for i, Result in enumerate(Data["response"]["results"]): + Choices += "{0}, {1}".format(Result["city"], + Result["state"] if Result["state"] else Result ["country_iso3166"]) + + if i < NumResults - 1: + Choices += " | " + + self.Torchlight().SayPrivate(player, "[WU] Did you mean: {0}".format(Choices)) + return 6 + + Observation = Data["current_observation"] + + self.Torchlight().SayChat("[{0}, {1}] {2}°C ({3}F) {4} | Wind {5} {6}kph ({7}mph) | Humidity: {8}".format(Observation["display_location"]["city"], + Observation["display_location"]["state"] if Observation["display_location"]["state"] else Observation["display_location"]["country_iso3166"], + Observation["temp_c"], Observation["temp_f"], Observation["weather"], + Observation["wind_dir"], Observation["wind_kph"], Observation["wind_mph"], + Observation["relative_humidity"])) + + return 0 + +### LEVEL 0 COMMANDS ### + +### LIMITED LEVEL 0 COMMANDS ### +class VoiceCommands(BaseCommand): + import json + import random + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!random"] + self.Level = 0 + + def LoadTriggers(self): + try: + with open("triggers.json", "r") as fp: + self.VoiceTriggers = self.json.load(fp) + except ValueError as e: + self.Logger.error(sys._getframe().f_code.co_name + ' ' + str(e)) + self.Torchlight().SayChat(str(e)) + + def _setup(self): + self.Logger.debug(sys._getframe().f_code.co_name) + self.LoadTriggers() + for Triggers in self.VoiceTriggers: + for Trigger in Triggers["names"]: + self.Triggers.append(Trigger) + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + Level = 0 + if player.Access: + Level = player.Access["level"] + + Disabled = self.Torchlight().Disabled + if Disabled and (Disabled > Level or Disabled == Level and Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]): + self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!") + return 1 + + if message[0][0] == '_' and Level < 2: + return 1 + + if message[0].lower() == "!random": + Trigger = self.random.choice(self.VoiceTriggers) + if isinstance(Trigger["sound"], list): + Sound = self.random.choice(Trigger["sound"]) + else: + Sound = Trigger["sound"] + else: + for Trigger in self.VoiceTriggers: + for Name in Trigger["names"]: + if message[0].lower() == Name: + Num = Utils.GetNum(message[1]) + if Num: + Num = int(Num) + + if isinstance(Trigger["sound"], list): + if Num and Num > 0 and Num <= len(Trigger["sound"]): + Sound = Trigger["sound"][Num - 1] + else: + Sound = self.random.choice(Trigger["sound"]) + else: + Sound = Trigger["sound"] + + break + + Path = os.path.abspath(os.path.join("sounds", Sound)) + AudioClip = self.Torchlight().AudioManager.AudioClip(player, "file://" + Path) + if not AudioClip: + return 1 + + return AudioClip.Play() + +class YouTube(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!yt"] + self.Level = 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + Level = 0 + if player.Access: + Level = player.Access["level"] + + Disabled = self.Torchlight().Disabled + if Disabled and (Disabled > Level or Disabled == Level and Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]): + self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!") + return 1 + + if self.Torchlight().LastUrl: + message[1] = message[1].replace("!last", self.Torchlight().LastUrl) + + Temp = DataHolder() + Time = None + + if Temp(message[1].find("&t=")) != -1 or Temp(message[1].find("?t=")) != -1 or Temp(message[1].find("#t=")) != -1: + TimeStr = message[1][Temp.value + 3:].split('&')[0].split('?')[0].split('#')[0] + if TimeStr: + Time = Utils.ParseTime(TimeStr) + + AudioClip = self.Torchlight().AudioManager.AudioClip(player, message[1]) + if not AudioClip: + return 1 + + return AudioClip.Play(Time) + +class YouTubeSearch(BaseCommand): + import json + import datetime + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!yts"] + self.Level = 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + Level = 0 + if player.Access: + Level = player.Access["level"] + + Disabled = self.Torchlight().Disabled + if Disabled and (Disabled > Level or Disabled == Level and Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]): + self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!") + return 1 + + Temp = DataHolder() + Time = None + + if Temp(message[1].find("&t=")) != -1 or Temp(message[1].find("?t=")) != -1 or Temp(message[1].find("#t=")) != -1: + TimeStr = message[1][Temp.value + 3:].split('&')[0].split('?')[0].split('#')[0] + if TimeStr: + Time = Utils.ParseTime(TimeStr) + message[1] = message[1][:Temp.value] + + Proc = await asyncio.create_subprocess_exec("youtube-dl", "--dump-json", "-xg", "ytsearch:" + message[1], + stdout = asyncio.subprocess.PIPE) + Out, _ = await Proc.communicate() + + url, Info = Out.split(b'\n', maxsplit = 1) + url = url.strip().decode("ascii") + Info = self.json.loads(Info) + + if Info["extractor_key"] == "Youtube": + self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2}/5.00 | {3:,}".format( + Info["title"], str(self.datetime.timedelta(seconds = Info["duration"])), round(Info["average_rating"], 2), int(Info["view_count"]))) + + AudioClip = self.Torchlight().AudioManager.AudioClip(player, url) + if not AudioClip: + return 1 + + self.Torchlight().LastUrl = url + + return AudioClip.Play(Time) + +class Say(BaseCommand): + import gtts + import tempfile + VALID_LANGUAGES = [lang for lang in gtts.gTTS.LANGUAGES.keys()] + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = [("!say", 4)] + self.Level = 0 + + async def Say(self, player, language, message): + GTTS = self.gtts.gTTS(text = message, lang = language, debug = False) + + TempFile = self.tempfile.NamedTemporaryFile(delete = False) + GTTS.write_to_fp(TempFile) + TempFile.close() + + AudioClip = self.Torchlight().AudioManager.AudioClip(player, "file://" + TempFile.name) + if not AudioClip: + os.unlink(TempFile.name) + return 1 + + if AudioClip.Play(): + AudioClip.AudioPlayer.AddCallback("Stop", lambda: os.unlink(TempFile.name)) + return 0 + else: + os.unlink(TempFile.name) + return 1 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + Level = 0 + if player.Access: + Level = player.Access["level"] + + Disabled = self.Torchlight().Disabled + if Disabled and (Disabled > Level or Disabled == Level and Level < self.Torchlight().Config["AntiSpam"]["ImmunityLevel"]): + self.Torchlight().SayPrivate(player, "Torchlight is currently disabled!") + return 1 + + if not message[1]: + return 1 + + Language = "en" + if len(message[0]) > 4: + Language = message[0][4:] + + if not Language in self.VALID_LANGUAGES: + return 1 + + asyncio.ensure_future(self.Say(player, Language, message[1])) + return 0 + +class Stop(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!stop"] + self.Level = 0 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + + self.Torchlight().AudioManager.Stop(player, message[1]) + return True + +### LIMITED LEVEL 0 COMMANDS ### + + +### LEVEL 1 COMMANDS ### +### LEVEL 1 COMMANDS ### + + +### LEVEL 2 COMMANDS ### +### LEVEL 2 COMMANDS ### + + +### LEVEL 3 COMMANDS ### +class EnableDisable(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!enable", "!disable"] + self.Level = 3 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + if message[0] == "!enable": + if self.Torchlight().Disabled: + if self.Torchlight().Disabled > player.Access["level"]: + self.Torchlight().SayPrivate(player, "You don't have access to enable torchlight since it was disabled by a higher level user.") + return 1 + self.Torchlight().SayChat("Torchlight has been enabled for the duration of this map - Type !disable to disable it again.") + + self.Torchlight().Disabled = False + + elif message[0] == "!disable": + if not self.Torchlight().Disabled: + self.Torchlight().SayChat("Torchlight has been disabled for the duration of this map - Type !enable to enable it again.") + + self.Torchlight().Disabled = player.Access["level"] +### LEVEL 3 COMMANDS ### + + +### LEVEL 4 COMMANDS ### +class AdminAccess(BaseCommand): + from collections import OrderedDict + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!access"] + self.Level = 4 + + def ReloadValidUsers(self): + self.Torchlight().Access.Load() + for Player in self.Torchlight().Players: + Access = self.Torchlight().Access[Player.UniqueID] + Player.Access = Access + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + if not message[1]: + return -1 + + if message[1].lower() == "reload": + self.ReloadValidUsers() + self.Torchlight().SayChat("Loaded access list with {0} users".format(len(self.Torchlight().Access))) + + elif message[1].lower() == "save": + self.Torchlight().Access.Save() + self.Torchlight().SayChat("Saved access list with {0} users".format(len(self.Torchlight().Access))) + + # Modify access + else: + Player = None + Buf = message[1] + Temp = Buf.find(" as ") + if Temp != -1: + try: + Regname, Level = Buf[Temp + 4:].rsplit(' ', 1) + except ValueError as e: + self.Torchlight().SayChat(str(e)) + return 1 + + Regname = Regname.strip() + Level = Level.strip() + Buf = Buf[:Temp].strip() + else: + try: + Buf, Level = Buf.rsplit(' ', 1) + except ValueError as e: + self.Torchlight().SayChat(str(e)) + return 2 + + Buf = Buf.strip() + Level = Level.strip() + + # Find user by User ID + if Buf[0] == '#' and Buf[1:].isnumeric(): + Player = self.Torchlight().Players.FindUserID(int(Buf[1:])) + # Search user by name + else: + for Player_ in self.Torchlight().Players: + if Player_.Name.lower().find(Buf.lower()) != -1: + Player = Player_ + break + + if not Player: + self.Torchlight().SayChat("Couldn't find user: {0}".format(Buf)) + return 3 + + if Level.isnumeric() or (Level.startswith('-') and Level[1:].isdigit()): + Level = int(Level) + + if Level >= player.Access["level"] and player.Access["level"] < 10: + self.Torchlight().SayChat("Trying to assign level {0}, which is higher or equal than your level ({1})".format(Level, player.Access["level"])) + return 4 + + if Player.Access: + if Player.Access["level"] >= player.Access["level"] and player.Access["level"] < 10: + self.Torchlight().SayChat("Trying to modify level {0}, which is higher or equal than your level ({1})".format(Player.Access["level"], player.Access["level"])) + return 5 + + if "Regname" in locals(): + self.Torchlight().SayChat("Changed \"{0}\"({1}) as {2} level/name from {3} to {4} as {5}".format( + Player.Name, Player.UniqueID, Player.Access["name"], Player.Access["level"], Level, Regname)) + Player.Access["name"] = Regname + else: + self.Torchlight().SayChat("Changed \"{0}\"({1}) as {2} level from {3} to {4}".format( + Player.Name, Player.UniqueID, Player.Access["name"], Player.Access["level"], Level)) + + Player.Access["level"] = Level + self.Torchlight().Access[Player.UniqueID] = Player.Access + else: + if not "Regname" in locals(): + Regname = Player.Name + + self.Torchlight().Access[Player.UniqueID] = self.OrderedDict([("name", Regname), ("level", Level)]) + Player.Access = self.Torchlight().Access[Player.UniqueID] + self.Torchlight().SayChat("Added \"{0}\"({1}) to access list as {2} with level {3}".format(Player.Name, Player.UniqueID, Regname, Level)) + else: + if Level == "revoke" and Player.Access: + if Player.Access["level"] >= player.Access["level"] and player.Access["level"] < 10: + self.Torchlight().SayChat("Trying to revoke level {0}, which is higher or equal than your level ({1})".format(Player.Access["level"], player.Access["level"])) + return 6 + + self.Torchlight().SayChat("Removed \"{0}\"({1}) from access list (was {2} with level {3})".format( + Player.Name, Player.UniqueID, Player.Access["name"], Player.Access["level"])) + del self.Torchlight().Access[Player.UniqueID] + Player.Access = None + return 0 +### LEVEL 4 COMMANDS ### + + +### LEVEL X COMMANDS ### +class Exec(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!exec"] + self.Level = 9 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + try: + Response = eval(message[1]) + except Exception as e: + self.Torchlight().SayChat("Error: {0}".format(str(e))) + return 1 + self.Torchlight().SayChat(str(Response)) + return 0 + +class Reload(BaseCommand): + def __init__(self, torchlight): + super().__init__(torchlight) + self.Triggers = ["!reload"] + self.Level = 6 + + async def _func(self, message, player): + self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message)) + self.Torchlight().Reload() + return 0 +### LEVEL X COMMANDS ### diff --git a/Torchlight/Config.py b/Torchlight/Config.py new file mode 100644 index 0000000..dc0fbd7 --- /dev/null +++ b/Torchlight/Config.py @@ -0,0 +1,24 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import json + +class Config(): + def __init__(self): + self.Logger = logging.getLogger(__class__.__name__) + self.Config = dict() + self.Load() + + def Load(self): + try: + with open("config.json", "r") as fp: + self.Config = json.load(fp) + except ValueError as e: + self.Logger.error(sys._getframe().f_code.co_name + ' ' + str(e)) + return 1 + return 0 + + def __getitem__(self, key): + if key in self.Config: + return self.Config[key] + return None diff --git a/Torchlight/Constants.py b/Torchlight/Constants.py new file mode 100644 index 0000000..f631276 --- /dev/null +++ b/Torchlight/Constants.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +MAXPLAYERS = 65 + +ADMFLAG_RESERVATION = (1<<0) +ADMFLAG_GENERIC = (1<<1) +ADMFLAG_KICK = (1<<2) +ADMFLAG_BAN = (1<<3) +ADMFLAG_UNBAN = (1<<4) +ADMFLAG_SLAY = (1<<5) +ADMFLAG_CHANGEMAP = (1<<6) +ADMFLAG_CONVARS = (1<<7) +ADMFLAG_CONFIG = (1<<8) +ADMFLAG_CHAT = (1<<9) +ADMFLAG_VOTE = (1<<10) +ADMFLAG_PASSWORD = (1<<11) +ADMFLAG_RCON = (1<<12) +ADMFLAG_CHEATS = (1<<13) +ADMFLAG_ROOT = (1<<14) +ADMFLAG_CUSTOM1 = (1<<15) +ADMFLAG_CUSTOM2 = (1<<16) +ADMFLAG_CUSTOM3 = (1<<17) +ADMFLAG_CUSTOM4 = (1<<18) +ADMFLAG_CUSTOM5 = (1<<19) +ADMFLAG_CUSTOM6 = (1<<20) diff --git a/Torchlight/FFmpegAudioPlayer.py b/Torchlight/FFmpegAudioPlayer.py new file mode 100644 index 0000000..82d75b8 --- /dev/null +++ b/Torchlight/FFmpegAudioPlayer.py @@ -0,0 +1,178 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import traceback +import asyncio +import datetime +import time +import socket +import struct +import sys + +SAMPLEBYTES = 2 + +class FFmpegAudioPlayerFactory(): + VALID_CALLBACKS = ["Play", "Stop", "Update"] + + def __init__(self, master): + self.Logger = logging.getLogger(__class__.__name__) + self.Master = master + self.Torchlight = self.Master.Torchlight + + def __del__(self): + self.Master.Logger.info("~FFmpegAudioPlayerFactory()") + self.Quit() + + def NewPlayer(self): + self.Logger.debug(sys._getframe().f_code.co_name) + Player = FFmpegAudioPlayer(self) + return Player + + def Quit(self): + self.Master.Logger.info("FFmpegAudioPlayerFactory->Quit()") + + +class FFmpegAudioPlayer(): + def __init__(self, master): + self.Master = master + self.Torchlight = self.Master.Torchlight + self.Playing = False + + self.Host = ( + self.Torchlight().Config["VoiceServer"]["Host"], + self.Torchlight().Config["VoiceServer"]["Port"] + ) + self.SampleRate = float(self.Torchlight().Config["VoiceServer"]["SampleRate"]) + + self.StartedPlaying = None + self.StoppedPlaying = None + self.Seconds = 0.0 + + self.Writer = None + self.Process = None + + self.Callbacks = [] + + def __del__(self): + self.Master.Logger.debug("~FFmpegAudioPlayer()") + self.Stop() + + def PlayURI(self, uri, position = None): + if position: + PosStr = str(datetime.timedelta(seconds = position)) + Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-"] + else: + Command = ["/usr/bin/ffmpeg", "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-"] + + self.Playing = True + asyncio.ensure_future(self._stream_subprocess(Command)) + return True + + def Stop(self, force = True): + if not self.Playing: + return False + + if self.Process: + try: + self.Process.terminate() + self.Process.kill() + self.Process = None + except ProcessLookupError: + pass + + if self.Writer: + if force: + Socket = self.Writer.transport.get_extra_info("socket") + if Socket: + Socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, + struct.pack("ii", 1, 0)) + + self.Writer.transport.abort() + + self.Writer.close() + + self.Playing = False + + self.Callback("Stop") + del self.Callbacks + + return True + + def AddCallback(self, cbtype, cbfunc): + if not cbtype in FFmpegAudioPlayerFactory.VALID_CALLBACKS: + return False + + self.Callbacks.append((cbtype, cbfunc)) + return True + + def Callback(self, cbtype, *args, **kwargs): + for Callback in self.Callbacks: + if Callback[0] == cbtype: + try: + Callback[1](*args, **kwargs) + except Exception as e: + self.Master.Logger.error(traceback.format_exc()) + + async def _updater(self): + LastSecondsElapsed = 0.0 + + while self.Playing: + SecondsElapsed = time.time() - self.StartedPlaying + + if SecondsElapsed > self.Seconds: + SecondsElapsed = self.Seconds + + self.Callback("Update", LastSecondsElapsed, SecondsElapsed) + + if SecondsElapsed >= self.Seconds: + if not self.StoppedPlaying: + print("BUFFER UNDERRUN!") + self.Stop(False) + return + + LastSecondsElapsed = SecondsElapsed + + await asyncio.sleep(0.1) + + async def _read_stream(self, stream, writer): + Started = False + + while stream and self.Playing: + Data = await stream.read(65536) + + if Data: + writer.write(Data) + await writer.drain() + + Bytes = len(Data) + Samples = Bytes / SAMPLEBYTES + Seconds = Samples / self.SampleRate + + self.Seconds += Seconds + + if not Started: + Started = True + self.Callback("Play") + self.StartedPlaying = time.time() + asyncio.ensure_future(self._updater()) + else: + self.Process = None + break + + self.StoppedPlaying = time.time() + + async def _stream_subprocess(self, cmd): + if not self.Playing: + return + + _, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1]) + + Process = await asyncio.create_subprocess_exec(*cmd, + stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL) + self.Process = Process + + await self._read_stream(Process.stdout, self.Writer) + await Process.wait() + + if self.Seconds == 0.0: + self.Stop() diff --git a/Torchlight/GameEvents.py b/Torchlight/GameEvents.py new file mode 100644 index 0000000..699d18c --- /dev/null +++ b/Torchlight/GameEvents.py @@ -0,0 +1,142 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import asyncio + +class GameEvents(): + def __init__(self, master): + self.Torchlight = master + + self.Callbacks = {} + + def __del__(self): + if not len(self.Callbacks) or not self.Torchlight(): + return + + Obj = { + "method": "unsubscribe", + "module": "gameevents", + "events": self.Callbacks.keys() + } + + asyncio.ensure_future(self.Torchlight().Send(Obj)) + + async def _Register(self, events): + if type(events) is not list: + events = [ events ] + + Obj = { + "method": "subscribe", + "module": "gameevents", + "events": events + } + + Res = await self.Torchlight().Send(Obj) + + Ret = [] + for i, ret in enumerate(Res["events"]): + if ret >= 0: + Ret.append(True) + if not events[i] in self.Callbacks: + self.Callbacks[events[i]] = set() + else: + Ret.append(False) + + if len(Ret) == 1: + Ret = Ret[0] + return Ret + + async def _Unregister(self, events): + if type(events) is not list: + events = [ events ] + + Obj = { + "method": "unsubscribe", + "module": "gameevents", + "events": events + } + + Res = await self.Torchlight().Send(Obj) + + Ret = [] + for i, ret in enumerate(Res["events"]): + if ret >= 0: + Ret.append(True) + if events[i] in self.Callbacks: + del self.Callbacks[events[i]] + else: + Ret.append(False) + + if len(Ret) == 1: + Ret = Ret[0] + return Ret + + def HookEx(self, event, callback): + asyncio.ensure_future(self.Hook(event, callback)) + + def UnhookEx(self, event, callback): + asyncio.ensure_future(self.Unhook(event, callback)) + + def ReplayEx(self, events): + asyncio.ensure_future(self.Replay(events)) + + async def Hook(self, event, callback): + if not event in self.Callbacks: + if not await self._Register(event): + return False + + self.Callbacks[event].add(callback) + return True + + async def Unhook(self, event, callback): + if not event in self.Callbacks: + return True + + if not callback in self.Callbacks[event]: + return True + + self.Callbacks[event].discard(callback) + + if len(a) == 0: + return await self._Unregister(event) + + return True + + async def Replay(self, events): + if type(events) is not list: + events = [ events ] + + for event in events[:]: + if not event in self.Callbacks: + events.remove(event) + + Obj = { + "method": "replay", + "module": "gameevents", + "events": events + } + + Res = await self.Torchlight().Send(Obj) + + Ret = [] + for i, ret in enumerate(Res["events"]): + if ret >= 0: + Ret.append(True) + else: + Ret.append(False) + + if len(Ret) == 1: + Ret = Ret[0] + return Ret + + def OnPublish(self, obj): + Event = obj["event"] + + if not Event["name"] in self.Callbacks: + return False + + Callbacks = self.Callbacks[Event["name"]] + + for Callback in Callbacks: + Callback(**Event["data"]) + + return True diff --git a/Torchlight/PlayerManager.py b/Torchlight/PlayerManager.py new file mode 100644 index 0000000..033fd2e --- /dev/null +++ b/Torchlight/PlayerManager.py @@ -0,0 +1,188 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import asyncio +import logging +import numpy +from .Constants import * + +class PlayerManager(): + def __init__(self, master): + self.Logger = logging.getLogger(__class__.__name__) + self.Torchlight = master + + self.Players = numpy.empty(MAXPLAYERS + 1, dtype = object) + self.Storage = self.StorageManager(self) + + self.Torchlight().GameEvents.HookEx("player_connect", self.Event_PlayerConnect) + self.Torchlight().GameEvents.HookEx("player_activate", self.Event_PlayerActivate) + self.Torchlight().GameEvents.HookEx("player_info", self.Event_PlayerInfo) + self.Torchlight().GameEvents.HookEx("player_disconnect", self.Event_PlayerDisconnect) + self.Torchlight().GameEvents.HookEx("server_spawn", self.Event_ServerSpawn) + + def Event_PlayerConnect(self, name, index, userid, networkid, address, bot): + index += 1 + self.Logger.info("OnConnect(name={0}, index={1}, userid={2}, networkid={3}, address={4}, bot={5})" + .format(name, index, userid, networkid, address, bot)) + assert self.Players[index] == None + + self.Players[index] = self.Player(self, index, userid, networkid, address, name) + self.Players[index].OnConnect() + + def Event_PlayerActivate(self, userid): + self.Logger.info("Pre_OnActivate(userid={0})".format(userid)) + index = self.FindUserID(userid).Index + self.Logger.info("OnActivate(index={0}, userid={1})".format(index, userid)) + + self.Players[index].OnActivate() + + def Event_PlayerInfo(self, name, index, userid, networkid, bot): + index += 1 + self.Logger.info("OnInfo(name={0}, index={1}, userid={2}, networkid={3}, bot={4})" + .format(name, index, userid, networkid, bot)) + + # We've connected to the server and receive info events about the already connected players + # Emulate connect message + if not self.Players[index]: + self.Event_PlayerConnect(name, index - 1, userid, networkid, bot) + else: + self.Players[index].OnInfo(name) + + def Event_PlayerDisconnect(self, userid, reason, name, networkid, bot): + index = self.FindUserID(userid).Index + self.Logger.info("OnDisconnect(index={0}, userid={1}, reason={2}, name={3}, networkid={4}, bot={5})" + .format(index, userid, reason, name, networkid, bot)) + + self.Players[index].OnDisconnect(reason) + self.Players[index] = None + + def Event_ServerSpawn(self, hostname, address, ip, port, game, mapname, maxplayers, os, dedicated, password): + self.Logger.info("ServerSpawn(mapname={0})" + .format(mapname)) + + self.Storage.Reset() + + for i in range(1, self.Players.size): + if self.Players[i]: + self.Players[i].OnDisconnect("mapchange") + self.Players[i].OnConnect() + + def FindUniqueID(self, uniqueid): + for Player in self.Players: + if Player and Player.UniqueID == uniqueid: + return Player + + def FindUserID(self, userid): + for Player in self.Players: + if Player and Player.UserID == userid: + return Player + + def FindName(self, name): + for Player in self.Players: + if Player and Player.Name == name: + return Player + + def __len__(self): + Count = 0 + for i in range(1, self.Players.size): + if self.Players[i]: + Count += 1 + return Count + + def __setitem__(self, key, value): + if key > 0 and key <= MAXPLAYERS: + self.Players[key] = value + + def __getitem__(self, key): + if key > 0 and key <= MAXPLAYERS: + return self.Players[key] + + def __iter__(self): + for i in range(1, self.Players.size): + if self.Players[i]: + yield self.Players[i] + + class StorageManager(): + def __init__(self, master): + self.PlayerManager = master + self.Storage = dict() + + def Reset(self): + self.Storage = dict() + + def __getitem__(self, key): + if not key in self.Storage: + self.Storage[key] = dict() + + return self.Storage[key] + + class Admin(): + def __init__(self): + self._FlagBits = 0 + + def FlagBits(self): + return self._FlagBits + + def Reservation(self): return (self._FlagBits & ADMFLAG_RESERVATION) + def Generic(self): return (self._FlagBits & ADMFLAG_GENERIC) + def Kick(self): return (self._FlagBits & ADMFLAG_KICK) + def Ban(self): return (self._FlagBits & ADMFLAG_BAN) + def Unban(self): return (self._FlagBits & ADMFLAG_UNBAN) + def Slay(self): return (self._FlagBits & ADMFLAG_SLAY) + def Changemap(self): return (self._FlagBits & ADMFLAG_CHANGEMAP) + def Convars(self): return (self._FlagBits & ADMFLAG_CONVARS) + def Config(self): return (self._FlagBits & ADMFLAG_CONFIG) + def Chat(self): return (self._FlagBits & ADMFLAG_CHAT) + def Vote(self): return (self._FlagBits & ADMFLAG_VOTE) + def Password(self): return (self._FlagBits & ADMFLAG_PASSWORD) + def RCON(self): return (self._FlagBits & ADMFLAG_RCON) + def Cheats(self): return (self._FlagBits & ADMFLAG_CHEATS) + def Root(self): return (self._FlagBits & ADMFLAG_ROOT) + def Custom1(self): return (self._FlagBits & ADMFLAG_CUSTOM1) + def Custom2(self): return (self._FlagBits & ADMFLAG_CUSTOM2) + def Custom3(self): return (self._FlagBits & ADMFLAG_CUSTOM3) + def Custom4(self): return (self._FlagBits & ADMFLAG_CUSTOM4) + def Custom5(self): return (self._FlagBits & ADMFLAG_CUSTOM5) + def Custom6(self): return (self._FlagBits & ADMFLAG_CUSTOM6) + + class Player(): + def __init__(self, master, index, userid, uniqueid, address, name): + self.PlayerManager = master + self.Torchlight = self.PlayerManager.Torchlight + self.Index = index + self.UserID = userid + self.UniqueID = uniqueid + self.Address = address + self.Name = name + self.Access = None + self.Admin = self.PlayerManager.Admin() + self.Storage = None + self.Active = False + + def OnConnect(self): + self.Storage = self.PlayerManager.Storage[self.UniqueID] + + if not "Audio" in self.Storage: + self.Storage["Audio"] = dict({"Uses": 0, "LastUse": 0.0, "LastUseLength": 0.0, "TimeUsed": 0.0}) + + self.Access = self.Torchlight().Access[self.UniqueID] + + def OnActivate(self): + self.Active = True + asyncio.ensure_future(self.OnPostActivate()) + + async def OnPostActivate(self): + self.Admin._FlagBits = (await self.Torchlight().API.GetUserFlagBits(self.Index))["result"] + self.PlayerManager.Logger.info("#{0} \"{1}\"({2}) FlagBits: {3}".format(self.UserID, self.Name, self.UniqueID, self.Admin._FlagBits)) + if not self.Access: + if self.Admin.Generic(): + self.Access = dict({"level": 3, "name": "Admin"}) + elif self.Admin.Custom1(): + self.Access = dict({"level": 1, "name": "VIP"}) + + def OnInfo(self, name): + self.Name = name + + def OnDisconnect(self, message): + self.Active = False + self.Storage = None + self.Torchlight().AudioManager.OnDisconnect(self) diff --git a/Torchlight/SourceModAPI.py b/Torchlight/SourceModAPI.py new file mode 100644 index 0000000..8c00e9f --- /dev/null +++ b/Torchlight/SourceModAPI.py @@ -0,0 +1,27 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import functools + +class SourceModAPI: + def __init__(self, master): + self.Torchlight = master + + def __getattr__(self, attr): + try: + return super(SourceModAPI, self).__getattr__(attr) + except AttributeError: + return functools.partial(self._MakeCall, attr) + + async def _MakeCall(self, function, *args, **kwargs): + Obj = { + "method": "function", + "function": function, + "args": args + } + + Res = await self.Torchlight().Send(Obj) + + if Res["error"]: + raise Exception("{0}({1})\n{2}".format(function, args, Res["error"])) + + return Res diff --git a/Torchlight/SourceRCONServer.py b/Torchlight/SourceRCONServer.py new file mode 100644 index 0000000..1467830 --- /dev/null +++ b/Torchlight/SourceRCONServer.py @@ -0,0 +1,105 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import asyncio +import sys +import socket +import struct +import time +import traceback +from importlib import reload +from .PlayerManager import PlayerManager + +class SourceRCONServer(): + class SourceRCONClient(): + def __init__(self, Server, Socket, Name): + self.Loop = Server.Loop + self.Server = Server + self._sock = Socket + self.Name = Name + self.Authenticated = False + asyncio.Task(self._peer_handler()) + + def send(self, data): + return self.Loop.sock_sendall(self._sock, data) + + @asyncio.coroutine + def _peer_handler(self): + try: + yield from self._peer_loop() + except IOError: + pass + finally: + self.Server.Remove(self) + + @asyncio.coroutine + def _peer_loop(self): + while True: + Data = yield from self.Loop.sock_recv(self._sock, 1024) + if Data == b'': + break + + while Data: + p_size = struct.unpack(" 976: + message = message[:973] + "..." + lines = textwrap.wrap(message, 244, break_long_words = True) + for line in lines: + asyncio.ensure_future(self.API.PrintToChatAll(line)) + + def SayPrivate(self, player, message): + asyncio.ensure_future(self.API.PrintToChat(player.Index, "\x0700FFFA[Torchlight]: \x01{0}".format(message))) + + def Reload(self): + self.Config.Load() + self.CommandHandler.NeedsReload = True + + async def Send(self, data): + return await self.Master.Send(data) + + def OnPublish(self, obj): + if obj["module"] == "gameevents": + self.GameEvents.OnPublish(obj) + + def Event_ServerSpawn(self, hostname, address, ip, port, game, mapname, maxplayers, os, dedicated, password): + self.Disabled = 0 + + def Event_PlayerSay(self, userid, text): + if userid == 0: + return + + Player = self.Players.FindUserID(userid) + asyncio.ensure_future(self.CommandHandler.HandleCommand(text, Player)) + + def __del__(self): + self.Logger.debug("~Torchlight()") + + +class TorchlightHandler(): + def __init__(self, loop): + self.Logger = logging.getLogger(__class__.__name__) + self.Loop = loop if loop else asyncio.get_event_loop() + self._Client = None + self.Torchlight = None + self.Config = Config() + + asyncio.ensure_future(self._Connect(), loop = self.Loop) + + async def _Connect(self): + # Connect to API + self._Client = AsyncClient(self.Loop, self.Config["SMAPIServer"]["Host"], self.Config["SMAPIServer"]["Port"], self) + await self._Client.Connect() + + self.Torchlight = Torchlight(self) + + # Pre Hook for late load + await self.Torchlight.GameEvents._Register(["player_connect", "player_activate"]) + + self.Torchlight.InitModules() + + # Late load + await self.Torchlight.GameEvents.Replay(["player_connect", "player_activate"]) + + async def Send(self, data): + return await self._Client.Send(data) + + def OnPublish(self, obj): + self.Torchlight.OnPublish(obj) + + def OnDisconnect(self, exc): + self.Logger.info("OnDisconnect({0})".format(exc)) + self.Torchlight = None + + asyncio.ensure_future(self._Connect(), loop = self.Loop) + + def __del__(self): + self.Logger.debug("~TorchlightHandler()") diff --git a/Torchlight/Utils.py b/Torchlight/Utils.py new file mode 100644 index 0000000..047e972 --- /dev/null +++ b/Torchlight/Utils.py @@ -0,0 +1,94 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import math + +class DataHolder: + def __init__(self, value=None, attr_name='value'): + self._attr_name = attr_name + self.set(value) + def __call__(self, value): + return self.set(value) + def set(self, value): + setattr(self, self._attr_name, value) + return value + def get(self): + return getattr(self, self._attr_name) + +class Utils(): + @staticmethod + def GetNum(Text): + Ret = '' + for c in Text: + if c.isdigit(): + Ret += c + elif Ret: + break + elif c == '-': + Ret += c + + return Ret + + @staticmethod + def ParseTime(TimeStr): + Negative = False + Time = 0 + + while TimeStr: + Val = Utils.GetNum(TimeStr) + if not Val: + break + + Val = int(Val) + if not Val: + break + + if Val < 0: + TimeStr = TimeStr[1:] + if Time == 0: + Negative = True + Val = abs(Val) + + ValLen = int(math.log10(Val)) + 1 + if len(TimeStr) > ValLen: + Mult = TimeStr[ValLen].lower() + TimeStr = TimeStr[ValLen + 1:] + if Mult == 'h': + Val *= 3600 + elif Mult == 'm': + Val *= 60 + else: + TimeStr = None + + Time += Val + + if Negative: + return -Time + else: + return Time + + + @staticmethod + def HumanSize(size_bytes): + """ + format a size in bytes into a 'human' file size, e.g. bytes, KB, MB, GB, TB, PB + Note that bytes/KB will be reported in whole numbers but MB and above will have greater precision + e.g. 1 byte, 43 bytes, 443 KB, 4.3 MB, 4.43 GB, etc + """ + if size_bytes == 1: + # because I really hate unnecessary plurals + return "1 byte" + + suffixes_table = [('bytes', 0),('KB', 0),('MB', 1),('GB', 2),('TB', 2), ('PB', 2)] + + num = float(size_bytes) + for suffix, precision in suffixes_table: + if num < 1024.0: + break + num /= 1024.0 + + if precision == 0: + formatted_size = str(int(num)) + else: + formatted_size = str(round(num, ndigits=precision)) + + return "{0}{1}".format(formatted_size, suffix) diff --git a/Torchlight/__init__.py b/Torchlight/__init__.py new file mode 100644 index 0000000..f966456 --- /dev/null +++ b/Torchlight/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- diff --git a/access.json b/access.json new file mode 100644 index 0000000..2119d42 --- /dev/null +++ b/access.json @@ -0,0 +1,6 @@ +{ + "[U:1:51174697]": { + "name": "BotoX", + "level": 10 + } +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..0df485e --- /dev/null +++ b/config.json @@ -0,0 +1,48 @@ +{ + "VoiceServer": + { + "Host": "10.0.0.101", + "Port": 27020, + "SampleRate": 48000 + }, + "SMAPIServer": + { + "Host": "10.0.0.101", + "Port": 27021 + }, + + "AudioLimits": + { + "0": + { + "Uses": -1, + "TotalTime": 12.5, + "MaxLength": 5.0, + "DelayFactor": 10.0 + }, + "1": + { + "Uses": -1, + "TotalTime": 17.5, + "MaxLength": 5.0, + "DelayFactor": 5.0 + } + }, + "AntiSpam": + { + "MaxUsageSpan": 60, + "MaxUsageTime": 10, + "PunishDelay": 60, + "ImmunityLevel": 4 + }, + + "TorchRCON": + { + "Host": "0.0.0.0", + "Port": 27015, + "Password": "***" + }, + + "WolframAPIKey": "***", + "WundergroundAPIKey": "***" +} diff --git a/main.py b/main.py new file mode 100755 index 0000000..c02cd05 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +import logging +import asyncio +import os +import sys +import threading +import traceback +import gc +from importlib import reload + +global TorchMaster + +import Torchlight.Torchlight +from Torchlight.SourceRCONServer import SourceRCONServer + +if __name__ == '__main__': + logging.basicConfig(level = logging.DEBUG) + + Loop = asyncio.get_event_loop() + + global TorchMaster + TorchMaster = Torchlight.Torchlight.TorchlightHandler(Loop) + + # Handles new connections on 0.0.0.0:27015 + RCONConfig = TorchMaster.Config["TorchRCON"] + RCONServer = SourceRCONServer(Loop, TorchMaster, + Host = RCONConfig["Host"], + Port = RCONConfig["Port"], + Password = RCONConfig["Password"]) + + # Run! + Loop.run_forever() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5bf5fe2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +aiohttp +appdirs +async-timeout +beautifulsoup4 +certifi +chardet +gTTS +gTTS-token +idna +lxml +multidict +numpy +olefile +packaging +Pillow +pyparsing +python-magic +requests +six +urllib3 +yarl \ No newline at end of file diff --git a/sounds/Tutturuu_v1.wav b/sounds/Tutturuu_v1.wav new file mode 100644 index 0000000..6c6e17d Binary files /dev/null and b/sounds/Tutturuu_v1.wav differ diff --git a/triggers.json b/triggers.json new file mode 100644 index 0000000..5877865 --- /dev/null +++ b/triggers.json @@ -0,0 +1,3 @@ +[ + {"names": ["!tuturu"], "sound": "Tutturuu_v1.wav"} +]