Merge branch 'rewrite-3'

This commit is contained in:
Joscha 2017-09-04 21:06:12 +00:00
commit 3d12b070e8
14 changed files with 1220 additions and 2021 deletions

48
TestBot.py Normal file
View file

@ -0,0 +1,48 @@
import yaboli
from yaboli.utils import *
#class TestBot(Bot):
class TestBot(yaboli.Bot):
def __init__(self, nick):
super().__init__(nick=nick)
self.register_callback("tree", self.command_tree, specific=False)
#async def on_send(self, message):
#if message.content == "!spawnevil":
#bot = TestBot("TestSpawn")
#task, reason = await bot.connect("test")
#second = await self.room.send("We have " + ("a" if task else "no") + " task. Reason: " + reason, message.message_id)
#if task:
#await bot.stop()
#await self.room.send("Stopped." if task.done() else "Still running (!)", second.message_id)
#await self.room.send("All's over now.", message.message_id)
#elif message.content == "!tree":
#messages = [message]
#newmessages = []
#for i in range(2):
#for m in messages:
#for j in range(2):
#newm = await self.room.send(f"{m.content}.{j}", m.message_id)
#newmessages.append(newm)
#messages = newmessages
#newmessages = []
async def command_tree(self, message, args):
messages = [message]
newmessages = []
for i in range(2):
for m in messages:
for j in range(2):
newm = await self.room.send(f"{message.content}.{j}", m.message_id)
newmessages.append(newm)
messages = newmessages
newmessages = []
if __name__ == "__main__":
bot = TestBot("TestSummoner")
run_controller(bot, "test")

View file

@ -1,10 +1,14 @@
from .bot import Bot import logging
from .botmanager import BotManager #logging.basicConfig(level=logging.DEBUG)
from .callbacks import Callbacks logging.basicConfig(level=logging.INFO)
from .connection import Connection
from .exceptions import * logger = logging.getLogger(__name__)
from .session import Session logger.setLevel(logging.DEBUG)
from .message import Message
from .sessions import Sessions from .bot import *
from .messages import Messages from .connection import *
from .room import Room from .controller import *
from .room import *
from .utils import *
__all__ = connection.__all__ + room.__all__ + controller.__all__ + utils.__all__

View file

@ -1,600 +1,176 @@
import asyncio
import logging
import re
import time import time
from .callbacks import *
from .controller import *
from .utils import *
from . import callbacks logger = logging.getLogger(__name__)
from . import exceptions __all__ = ["Bot"]
from . import room
class Bot():
"""
Empty bot class that can be built upon. class Bot(Controller):
Takes care of extended botrulez. # ^ and $ not needed since we're doing a re.fullmatch
""" SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)"
GENERIC_RE = r"!(\S+)([\S\s]*)"
def __init__(self, roomname, nick="yaboli", password=None, manager=None, def __init__(self, nick):
created_in=None, created_by=None): super().__init__(nick)
"""
roomname - name of the room to connect to
nick - nick to assume, None -> no nick
password - room password (in case the room is private)
created_in - room the bot was created in
created_by - nick of the person the bot was created by
"""
self.start_time = time.time() self.start_time = time.time()
self.created_by = created_by self._callbacks = Callbacks()
self.created_in = created_in self.register_default_callbacks()
self.manager = manager # settings (modify in your bot's __init__)
self.general_help = None # None -> does not respond to general help
# modify/customize this in your __init__() function (or anywhere else you want, for that matter) self.killable = True
self.bot_description = ("This bot complies with the botrulez™ (https://github.com/jedevc/botrulez),\n" self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not
"plus a few extra commands.")
self.helptexts = {}
self.detailed_helptexts = {}
self.room = room.Room(roomname, nick=nick, password=password)
self.room.add_callback("message", self.on_message)
self.commands = callbacks.Callbacks()
self.bot_specific_commands = []
self.add_command("clone", self.clone_command, "Clone this bot to another room.", # possibly add option to set nick?
("!clone @bot [ <room> [ --pw=<password> ] ]\n"
"<room> : the name of the room to clone the bot to\n"
"--pw : the room's password\n\n"
"Clone this bot to the room specified.\n"
"If the target room is passworded, you can use the --pw option to set\n"
"a password for the bot to use.\n"
"If no room is specified, this will use the current room and password."),
bot_specific=False)
self.add_command("help", self.help_command, "Show help information about the bot.",
("!help @bot [ -s | -c | <command> ]\n"
"-s : general syntax help\n"
"-c : only list the commands\n"
"<command> : any command from !help @bot -c\n\n"
"Shows detailed help for a command if you specify a command name.\n"
"Shows a list of commands and short description if no arguments are given."))
self.add_command("kill", self.kill_command, "Kill (stop) the bot.",
("!kill @bot [ -r ]\n"
"-r : restart the bot (will change the id)\n\n"
"The bot disconnects from the room and stops."))
self.add_command("ping", self.ping_command, "Replies 'Pong!'.",
("!ping @bot\n\n"
"This command was originally used to help distinguish bots from\n"
"people. Since the Great UI Change, this is no longer necessary as\n"
"bots and people are displayed separately."))
self.add_command("restart", self.restart_command, "Restart the bot (shorthand for !kill @bot -r).",
("!restart @bot\n\n"
"Restart the bot.\n"
"Short for !kill @bot -r"))
self.add_command("send", self.send_command, "Send the bot to another room.",
("!send @bot <room> [ --pw=<password> ]\n"
"--pw : the room's password\n\n"
"Sends this bot to the room specified. If the target room is passworded,\n"
"you can use the --pw option to set a password for the bot to use."))
self.add_command("uptime", self.uptime_command, "Show bot uptime since last (re-)start.",
("!uptime @bot [ -i s]\n"
"-i : show more detailed information\n\n"
"Shows the bot's uptime since the last start or restart.\n"
"Shows additional information (i.e. id) if the -i flag is set."))
self.add_command("show", self.show_command, detailed_helptext="You've found a hidden command! :)")
self.room.launch()
def stop(self): def register_callback(self, event, callback, specific=True):
""" self._callbacks.add((event, specific), callback)
stop() -> None
Kill this bot.
"""
self.room.stop()
def add_command(self, command, function, helptext=None, detailed_helptext=None, async def on_send(self, message):
bot_specific=True): parsed = self.parse_message(message.content)
""" if not parsed:
add_command(command, function, helptext, detailed_helptext, bot_specific) -> None return
command, args = parsed
Subscribe a function to a command and add a help text. # general callback (specific set to False)
If no help text is provided, the command won't be displayed by the !help command. general = asyncio.ensure_future(
This overwrites any previously added command. self._callbacks.call((command, False), message, args)
)
You can "hide" commands by specifying only the detailed helptext, if len(args) > 0:
or no helptext at all. mention = args[0]
args = args[1:]
if mention[:1] == "@" and similar(mention[1:], self.nick):
# specific callback (specific set to True)
await self._callbacks.call((command, True), message, args)
If the command is not bot specific, no id has to be specified if there are multiple bots await general
with the same nick in a room.
"""
command = command.lower()
self.commands.remove(command)
self.commands.add(command, function)
if helptext and not command in self.helptexts:
self.helptexts[command] = helptext
elif not helptext and command in self.helptexts:
self.helptexts.pop(command)
if detailed_helptext and not command in self.detailed_helptexts:
self.detailed_helptexts[command] = detailed_helptext
elif not detailed_helptext and command in self.detailed_helptexts:
self.detailed_helptexts.pop(command)
if bot_specific and not command in self.bot_specific_commands:
self.bot_specific_commands.append(command)
elif not bot_specific and command in self.bot_specific_commands:
self.bot_specific_commands.remove(command)
def call_command(self, message): def parse_message(self, content):
""" """
call_command(message) -> None (command, args) = parse_message(content)
Calls all functions subscribed to the command with the arguments supplied in the message. Returns None, not a (None, None) tuple, when message could not be parsed
Deals with the situation that multiple bots of the same type and nick are in the same room.
""" """
try: match = re.fullmatch(self.GENERIC_RE, content)
command, bot_id, nick, arguments, flags, options = self.parse(message.content) if not match:
except exceptions.ParseMessageException: return None
return
else:
command = command.lower()
nick = self.room.mentionable(nick).lower()
if not self.commands.exists(command): command = match.group(1)
return argstr = match.group(2)
args = self.parse_args(argstr)
if not nick == self.mentionable().lower(): return command, args
return
def parse_args(self, text):
"""
Use single- and double-quotes bash-style to include whitespace in arguments.
A backslash always escapes the next character.
Any non-escaped whitespace separates arguments.
if bot_id is not None: # id specified Returns a list of arguments.
if self.manager.get(bot_id) == self: Deals with unclosed quotes and backslashes without crashing.
self.commands.call(command, message, arguments, flags, options) """
escape = False
quote = None
args = []
arg = ""
for character in text:
if escape:
arg += character
escape = False
elif character == "\\":
escape = True
elif quote:
if character == quote:
quote = None
else:
arg += character
elif character in "'\"":
quote = character
elif character.isspace():
if len(arg) > 0:
args.append(arg)
arg = ""
else: else:
return arg += character
else: # no id specified
bots = self.manager.get_similar(self.roomname(), nick)
if self.manager.get_id(self) == min(bots): # only one bot should act
# either the bot is unique or the command is not bot-specific
if not command in self.bot_specific_commands or len(bots) == 1:
self.commands.call(command, message, arguments, flags, options)
else: # user has to select a bot #if escape or quote:
msg = ("There are multiple bots with that nick in this room. To select one,\n" #return None # syntax error
"please specify its id (from the list below) as follows:\n"
"!{} <id> @{} [your arguments...]\n").format(command, nick)
for bot_id in sorted(bots):
bot = bots[bot_id]
msg += "\n{} - @{} ({})".format(bot_id, bot.mentionable(), bot.creation_info())
self.room.send_message(msg, parent=message.id)
def roomname(self):
"""
roomname() -> roomname
The room the bot is connected to. if len(arg) > 0:
""" args.append(arg)
return self.room.room
def password(self):
"""
password() -> password
The current room's password.
"""
return self.room.password
def nick(self):
"""
nick() -> nick
The bot's full nick.
"""
return self.room.nick
def mentionable(self):
"""
mentionable() -> nick
The bot's nick in a mentionable format.
"""
return self.room.mentionable()
def creation_info(self):
"""
creation_info() -> str
Formatted info about the bot's creation
"""
info = "created {}".format(self.format_date())
if self.created_by:
info += " by @{}".format(self.room.mentionable(self.created_by))
if self.created_in:
info += " in &{}".format(self.created_in)
return info
def format_date(self, seconds=None):
"""
format_date(seconds) -> str
Format a time in epoch format to the format specified in self.date_format.
Defaults to self.start_time.
"""
if seconds is None:
seconds = self.start_time
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds))
def format_delta(self, delta=None):
"""
format_delta(delta) -> str
Format a difference in seconds to the following format:
[- ][<days>d ][<hours>h ][<minutes>m ]<seconds>s
Defaults to the current uptime if no delta is specified.
"""
if not delta:
delta = time.time() - self.start_time
delta = int(delta)
uptime = ""
if delta < 0:
uptime += "- "
delta = -delta
if delta >= 24*60*60:
uptime +="{}d ".format(delta//(24*60*60))
delta %= 24*60*60
if delta >= 60*60: return args
uptime += "{}h ".format(delta//(60*60))
delta %= 60*60
if delta >= 60:
uptime += "{}m ".format(delta//60)
delta %= 60
uptime += "{}s".format(delta)
return uptime
def parse_command(self, message): def parse_flags(self, arglist):
"""
parse_command(message_content) -> command, bot_id, nick, argpart
Parse the "!command[ bot_id] @botname[ argpart]" part of a command.
"""
# command name (!command)
split = message.split(maxsplit=1)
if len(split) < 2:
raise exceptions.ParseMessageException("Not enough arguments")
elif split[0][:1] != "!":
raise exceptions.ParseMessageException("Not a command")
command = split[0][1:]
message = split[1]
split = message.split(maxsplit=1)
# bot id
try:
bot_id = int(split[0])
except ValueError:
bot_id = None
else:
if len(split) <= 1:
raise exceptions.ParseMessageException("No bot nick")
message = split[1]
split = message.split(maxsplit=1)
# bot nick (@mention)
if split[0][:1] != "@":
raise exceptions.ParseMessageException("No bot nick")
nick = split[0][1:]
# arguments to the command
if len(split) > 1:
argpart = split[1]
else:
argpart = None
return command, bot_id, nick, argpart
def parse_arguments(self, argstr):
"""
parse_arguments(argstr) -> arguments, flags, options
Parse the argument part of a command.
"""
argstr += " " # so the last argument will also be captured
escaping = False
quot_marks = None
type_signs = 0
option = None
word = ""
arguments = []
flags = "" flags = ""
options = {} args = []
kwargs = {}
for char in argstr: for arg in arglist:
# kwargs (--abc, --foo=bar)
# backslash-escaping if arg[:2] == "--":
if escaping: arg = arg[2:]
word += char if "=" in arg:
escaping = False s = arg.split("=", maxsplit=1)
elif char == "\\": kwargs[s[0]] = s[1]
escaping = True
# quotation mark escaped strings
elif quot_marks:
if char == quot_marks:
quot_marks = None
else: else:
word += char kwargs[arg] = None
elif char in ['"', "'"]: # flags (-x, -rw)
quot_marks = char elif arg[:1] == "-":
arg = arg[1:]
# type signs flags += arg
elif char == "-": # args (normal arguments)
if type_signs < 2 and not word:
type_signs += 1
else:
word += char
# "=" in options
elif char == "=" and type_signs == 2 and word and not option:
option = word
word = ""
# space - evaluate information collected so far
elif char == " ":
if word:
if type_signs == 0: # argument
arguments.append(word)
elif type_signs == 1: # flag(s)
for flag in word:
if not flag in flags:
flags += flag
elif type_signs == 2: # option
if option:
options[option] = word
else:
options[word] = True
# reset all state variables
escaping = False
quot_marks = None
type_signs = 0
option = None
word = ""
# all other chars and situations
else: else:
word += char args.append(arg)
return arguments, flags, options return flags, args, kwargs
def parse(self, message):
"""
parse(message_content) -> bool
Parse a message.
"""
command, bot_id, nick, argpart = self.parse_command(message)
if argpart:
arguments, flags, options = self.parse_arguments(argpart)
else:
arguments = []
flags = ""
options = {}
return command, bot_id, nick, arguments, flags, options
# ----- HANDLING OF EVENTS -----
def on_message(self, message): # BOTRULEZ COMMANDS
"""
on_message(message) -> None
Gets called when a message is received (see __init__).
If you want to add a command to your bot, consider using add_command instead of overwriting
this function.
"""
self.call_command(message)
# ----- COMMANDS ----- def register_default_callbacks(self):
self.register_callback("ping", self.command_ping)
self.register_callback("ping", self.command_ping, specific=False)
self.register_callback("help", self.command_help)
self.register_callback("help", self.command_help_general, specific=False)
self.register_callback("uptime", self.command_uptime)
self.register_callback("kill", self.command_kill)
# TODO: maybe !restart command
def clone_command(self, message, arguments, flags, options): async def command_ping(self, message, args):
""" await self.room.send("Pong!", message.message_id)
clone_command(message, *arguments, flags, options) -> None
Create a new bot.
"""
if not arguments:
room = self.roomname()
password = self.room.password
else:
room = arguments[0]
if room[:1] == "&":
room = room[1:]
if "pw" in options and options["pw"] is not True:
password = options["pw"]
else:
password = None
try:
bot = self.manager.create(room, password=password, created_in=self.roomname(),
created_by=message.sender.name)
except exceptions.CreateBotException:
self.room.send_message("Bot could not be cloned.", parent=message.id)
else:
self.room.send_message("Cloned @{} to &{}.".format(bot.mentionable(), room),
parent=message.id)
def help_command(self, message, arguments, flags, options): async def command_help(self, message, args):
""" await self.room.send("<placeholder help>", message.message_id)
help_command(message, *arguments, flags, options) -> None
Show help about the bot.
"""
if arguments: # detailed help for one command
command = arguments[0]
if command[:1] == "!":
command = command[1:]
if command in self.detailed_helptexts:
msg = "Detailed help for !{}:\n".format(command)
msg += self.detailed_helptexts[command]
else:
msg = "No detailed help text found for !{}.".format(command)
if command in self.helptexts:
msg += "\n\n" + self.helptexts[command]
elif "s" in flags: # detailed syntax help
msg = ("SYNTAX HELP PLACEHOLDER")
else: # just list all commands
msg = ""
if not "c" in flags:
msg += self.bot_description + "\n\n"
msg += "This bot supports the following commands:"
for command in sorted(self.helptexts):
helptext = self.helptexts[command]
msg += "\n!{} - {}".format(command, helptext)
if not "c" in flags:
msg += ("\n\nFor help on the command syntax, try: !help @{0} -s\n"
"For detailed help on a command, try: !help @{0} <command>\n"
"(Hint: Most commands have extra functionality, which is listed in their detailed help.)")
msg = msg.format(self.mentionable())
self.room.send_message(msg, parent=message.id)
def kill_command(self, message, arguments, flags, options): async def command_help_general(self, message, args):
""" if self.general_help is not None:
kill_command(message, *arguments, flags, options) -> None await self.room.send(self.general_help, message.message_id)
stop the bot.
"""
if "r" in flags:
bot = self.manager.create(self.roomname())
bot.created_by = self.created_by
bot.created_in = self.created_in
self.room.send_message("/me exits.", message.id)
self.manager.remove(self.manager.get_id(self))
def ping_command(self, message, arguments, flags, options): async def command_uptime(self, message, args):
""" now = time.time()
ping_command(message, *arguments, flags, options) -> None startformat = format_time(self.start_time)
deltaformat = format_time_delta(now - self.start_time)
Send a "Pong!" reply on a !ping command. text = f"/me has been up since {startformat} ({deltaformat})"
""" await self.room.send(text, message.message_id)
self.room.send_message("Pong!", parent=message.id)
def restart_command(self, message, arguments, flags, options): async def command_kill(self, message, args):
""" logging.warn(f"Kill attempt in &{self.room.roomname}: {message.content!r}")
restart_command(message, *arguments, flags, options) -> None
Restart the bot (shorthand for !kill @bot -r). if self.kill_message is not None:
""" await self.room.send(self.kill_message, message.message_id)
self.commands.call("kill", message, [], "r", {}) if self.killable:
await self.stop()
def send_command(self, message, arguments, flags, options):
"""
_command(message, *arguments, flags, options) -> None
Send this bot to another room.
"""
if not arguments:
return
else:
room = arguments[0]
if room[:1] == "&":
room = room[1:]
if "pw" in options and options["pw"] is not True:
password = options["pw"]
else:
password = None
self.room.send_message("/me moves to &{}.".format(room), parent=message.id)
self.room.change(room, password=password)
self.room.launch()
def show_command(self, message, arguments, flags, options):
"""
show_command(message, arguments, flags, options) -> None
Show arguments, flags and options.
"""
msg = "arguments: {}\nflags: {}\noptions: {}".format(arguments, repr(flags), options)
self.room.send_message(msg, parent=message.id)
def uptime_command(self, message, arguments, flags, options):
"""
uptime_command(message, arguments, flags, options) -> None
Show uptime and other info.
"""
stime = self.format_date()
utime = self.format_delta()
if "i" in flags:
msg = "uptime: {} ({})".format(stime, utime)
msg += "\nid: {}".format(self.manager.get_id(self))
msg += "\n{}".format(self.creation_info())
else:
msg = "/me is up since {} ({}).".format(stime, utime)
self.room.send_message(msg, message.id)

View file

@ -1,149 +0,0 @@
import json
from . import bot
from . import exceptions
class BotManager():
"""
Keep track of multiple bots in different rooms.
"""
def __init__(self, bot_class, default_nick="yaboli", max_bots=100,
bots_file="bots.json", data_file="data.json"):
"""
bot_class - class to create instances of
default_nick - default nick for all bots to assume when no nick is specified
max_bots - maximum number of bots allowed to exist simultaneously
None or 0 - no limit
bots_file - file the bot backups are saved to
None - no bot backups
data_file - file the bot data is saved to
- None - bot data isn't saved
"""
self.bot_class = bot_class
self.max_bots = max_bots
self.default_nick = default_nick
self.bots_file = bots_file
self.data_file = data_file
self._bots = {}
self._bot_id = 0
self._bot_data = {}
self._load_bots()
def create(self, room, password=None, nick=None, created_in=None, created_by=None):
"""
create(room, password, nick) -> bot
Create a new bot in room.
"""
if nick is None:
nick = self.default_nick
if self.max_bots and len(self._bots) >= self.max_bots:
raise exceptions.CreateBotException("max_bots limit hit")
else:
bot = self.bot_class(room, nick=nick, password=password, manager=self,
created_in=created_in, created_by=created_by)
self._bots[self._bot_id] = bot
self._bot_id += 1
self._save_bots()
return bot
def remove(self, bot_id):
"""
remove(bot_id) -> None
Kill a bot and remove it from the list of bots.
"""
if bot_id in self._bots:
self._bots[bot_id].stop()
self._bots.pop(bot_id)
self._save_bots()
def get(self, bot_id):
"""
get(self, bot_id) -> bot
Return bot with that id, if found.
"""
if bot_id in self._bots:
return self._bots[bot_id]
def get_id(self, bot):
"""
get_id(bot) -> bot_id
Return the bot id, if the bot is known.
"""
for bot_id, own_bot in self._bots.items():
if bot == own_bot:
return bot_id
def get_similar(self, room, nick):
"""
get_by_room(room, nick) -> dict
Collect all bots that are connected to the room and have that nick.
"""
return {bot_id: bot for bot_id, bot in self._bots.items()
if bot.roomname() == room and bot.mentionable().lower() == nick.lower()}
def _load_bots(self):
"""
_load_bots() -> None
Load and create bots from self.bots_file.
"""
if not self.bots_file:
return
try:
with open(self.bots_file) as f:
bots = json.load(f)
except FileNotFoundError:
pass
else:
for bot_info in bots:
bot = self.create(bot_info["room"], password=bot_info["password"],
nick=bot_info["nick"])
bot.created_in = bot_info["created_in"]
bot.created_by = bot_info["created_by"]
def _save_bots(self):
"""
_save_bots() -> None
Save all current bots to self.bots_file.
"""
if not self.bots_file:
return
bots = []
for bot_id, bot in self._bots.items():
bot_info = {}
bot_info["room"] = bot.roomname()
bot_info["password"] = bot.password()
bot_info["nick"] = bot.nick()
bot_info["created_in"] = bot.created_in
bot_info["created_by"] = bot.created_by
bots.append(bot_info)
with open(self.bots_file, "w") as f:
json.dump(bots, f)

View file

@ -1,30 +1,27 @@
import asyncio
__all__ = ["Callbacks"]
class Callbacks(): class Callbacks():
""" """
Manage callbacks Manage callbacks asynchronously
""" """
def __init__(self): def __init__(self):
self._callbacks = {} self._callbacks = {}
def add(self, event, callback, *args, **kwargs): def add(self, event, callback):
""" """
add(event, callback, *args, **kwargs) -> None add(event, callback) -> None
Add a function to be called on event. Add a function to be called on event.
The function will be called with *args and **kwargs.
Certain arguments might be added, depending on the event.
""" """
if not event in self._callbacks: if not event in self._callbacks:
self._callbacks[event] = [] self._callbacks[event] = []
self._callbacks[event].append(callback)
callback_info = {
"callback": callback,
"args": args,
"kwargs": kwargs
}
self._callbacks[event].append(callback_info)
def remove(self, event): def remove(self, event):
""" """
@ -36,21 +33,18 @@ class Callbacks():
if event in self._callbacks: if event in self._callbacks:
del self._callbacks[event] del self._callbacks[event]
def call(self, event, *args): async def call(self, event, *args, **kwargs):
""" """
call(event) -> None await call(event) -> None
Call all callbacks subscribed to the event with *args and the arguments specified when the Call all callbacks subscribed to the event with *args and **kwargs".
callback was added.
""" """
if event in self._callbacks: tasks = [asyncio.ensure_future(callback(*args, **kwargs))
for c_info in self._callbacks[event]: for callback in self._callbacks.get(event, [])]
c = c_info["callback"]
args = c_info["args"] + args for task in tasks:
kwargs = c_info["kwargs"] await task
c(*args, **kwargs)
def exists(self, event): def exists(self, event):
""" """

View file

@ -1,229 +1,158 @@
import logging
logger = logging.getLogger(__name__)
import asyncio
asyncio.get_event_loop().set_debug(True)
import json import json
import time import websockets
import threading #from websockets import ConnectionClosed
import websocket
from websocket import WebSocketException as WSException
from . import callbacks __all__ = ["Connection"]
class Connection():
"""
Stays connected to a room in its own thread. class Connection:
Callback functions are called when a packet is received. def __init__(self, url, packet_hook, cookie=None):
self.url = url
Callbacks: self.cookie = cookie
- all the message types from api.euphoria.io self.packet_hook = packet_hook
These pass the packet data as argument to the called functions.
The other callbacks don't pass any special arguments.
- "connect"
- "disconnect"
- "stop"
"""
ROOM_FORMAT = "wss://euphoria.io/room/{}/ws"
def __init__(self, room, url_format=None):
"""
room - name of the room to connect to
"""
self.room = room
if not url_format:
url_format = self.ROOM_FORMAT
self._url = url_format.format(self.room)
self._stopping = False
self._ws = None self._ws = None
self._thread = None self._pid = 0 # successive packet ids
self._send_id = 0 self._spawned_tasks = set()
self._callbacks = callbacks.Callbacks() self._pending_responses = {}
self._id_callbacks = callbacks.Callbacks() #self._stopping = False
self._runtask = None
def _connect(self, tries=-1, delay=10): async def connect(self, max_tries=10, delay=60):
""" """
_connect(tries, delay) -> bool success = await connect(max_tries=10, delay=60)
tries - maximum number of retries Attempt to connect to a room.
-1 -> retry indefinitely Returns the task listening for packets, or None if the attempt failed.
Returns True on success, False on failure.
Connect to the room.
""" """
while tries != 0: logger.debug(f"Attempting to connect, max_tries={max_tries}")
await self.stop()
tries_left = max_tries
while tries_left > 0:
tries_left -= 1
try: try:
self._ws = websocket.create_connection( self._ws = await websockets.connect(self.url, max_size=None)
self._url, except (websockets.InvalidURI, websockets.InvalidHandshake):
enable_multithread=True self._ws = None
) if tries_left > 0:
await asyncio.sleep(delay)
self._callbacks.call("connect") else:
self._runtask = asyncio.ensure_future(self._run())
return True return self._runtask
except WSException:
if tries > 0:
tries -= 1
if tries != 0:
time.sleep(delay)
return False
def disconnect(self): async def _run(self):
""" """
disconnect() -> None Listen for packets and deal with them accordingly.
Reconnect to the room.
WARNING: To completely disconnect, use stop().
""" """
if self._ws: try:
self._ws.close() while True:
await self._handle_next_message()
except websockets.ConnectionClosed:
pass
finally:
self._clean_up_futures()
self._clean_up_tasks()
await self._ws.close() # just to make sure
self._ws = None self._ws = None
self._callbacks.call("disconnect")
def launch(self): async def stop(self):
""" """
launch() -> Thread Close websocket connection and wait for running task to stop.
Connect to the room and spawn a new thread running run.
"""
if self._connect(tries=1):
self._thread = threading.Thread(target=self._run,
name="{}-{}".format(self.room, int(time.time())))
self._thread.start()
return self._thread
else:
self.stop()
def _run(self):
"""
_run() -> None
Receive messages.
"""
while not self._stopping:
try:
self._handle_json(self._ws.recv())
except (WSException, ConnectionResetError):
if not self._stopping:
self.disconnect()
self._connect()
def stop(self):
"""
stop() -> None
Close the connection to the room.
Joins the thread launched by self.launch().
"""
self._stopping = True
self.disconnect()
self._callbacks.call("stop")
if self._thread and self._thread != threading.current_thread():
self._thread.join()
def next_id(self):
"""
next_id() -> id
Returns the id that will be used for the next package.
"""
return str(self._send_id)
def add_callback(self, ptype, callback, *args, **kwargs):
"""
add_callback(ptype, callback, *args, **kwargs) -> None
Add a function to be called when a packet of type ptype is received.
"""
self._callbacks.add(ptype, callback, *args, **kwargs)
def add_id_callback(self, pid, callback, *args, **kwargs):
"""
add_id_callback(pid, callback, *args, **kwargs) -> None
Add a function to be called when a packet with id pid is received.
"""
self._id_callbacks.add(pid, callback, *args, **kwargs)
def add_next_callback(self, callback, *args, **kwargs):
"""
add_next_callback(callback, *args, **kwargs) -> None
Add a function to be called when the answer to the next message sent is received.
"""
self._id_callbacks.add(self.next_id(), callback, *args, **kwargs)
def _handle_json(self, data):
"""
handle_json(data) -> None
Handle incoming 'raw' data.
"""
packet = json.loads(data)
self._handle_packet(packet)
def _handle_packet(self, packet):
"""
_handle_packet(ptype, data) -> None
Handle incoming packets
"""
if "data" in packet:
data = packet["data"]
else:
data = None
if "error" in packet:
error = packet["error"]
else:
error = None
self._callbacks.call(packet["type"], data, error)
if "id" in packet:
self._id_callbacks.call(packet["id"], data, error)
self._id_callbacks.remove(packet["id"])
def _send_json(self, data):
"""
_send_json(data) -> None
Send 'raw' json.
""" """
if self._ws: if self._ws:
try: await self._ws.close()
self._ws.send(json.dumps(data))
except WSException: if self._runtask:
self.disconnect() await self._runtask
def send_packet(self, ptype, **kwargs): async def send(self, ptype, data=None, await_response=True):
""" if not self._ws:
send_packet(ptype, **kwargs) -> None raise asyncio.CancelledError
Send a formatted packet.
"""
pid = str(self._new_pid())
packet = { packet = {
"type": ptype, "type": ptype,
"data": kwargs or None, "id": pid
"id": str(self._send_id)
} }
self._send_id += 1 if data:
self._send_json(packet) packet["data"] = data
if await_response:
wait_for = self._wait_for_response(pid)
logging.debug(f"Currently used websocket at self._ws: {self._ws}")
await self._ws.send(json.dumps(packet, separators=(',', ':'))) # minimum size
if await_response:
await wait_for
return wait_for.result()
def _new_pid(self):
self._pid += 1
return self._pid
async def _handle_next_message(self):
response = await self._ws.recv()
task = asyncio.ensure_future(self._handle_json(response))
self._track_task(task) # will be cancelled when the connection is closed
def _clean_up_futures(self):
for pid, future in self._pending_responses.items():
logger.debug(f"Cancelling future: {future}")
future.cancel()
self._pending_responses = {}
def _clean_up_tasks(self):
for task in self._spawned_tasks:
if not task.done():
logger.debug(f"Cancelling task: {task}")
task.cancel()
else:
logger.debug(f"Task already done: {task}")
logger.debug(f"Exception: {task.exception()}")
self._spawned_tasks = set()
async def _handle_json(self, text):
packet = json.loads(text)
# Deal with pending responses
pid = packet.get("id", None)
future = self._pending_responses.pop(pid, None)
if future:
future.set_result(packet)
# Pass packet onto room
await self.packet_hook(packet)
def _track_task(self, task):
self._spawned_tasks.add(task)
# only keep running tasks
#tasks = set()
#for task in self._spawned_tasks:
#if not task.done():
#logger.debug(f"Keeping task: {task}")
#tasks.add(task)
#else:
#logger.debug(f"Deleting task: {task}")
#self._spawned_tasks = tasks
self._spawned_tasks = {task for task in self._spawned_tasks if not task.done()} # TODO: Reenable
def _wait_for_response(self, pid):
future = asyncio.Future()
self._pending_responses[pid] = future
return future

205
yaboli/controller.py Normal file
View file

@ -0,0 +1,205 @@
import asyncio
import logging
from .room import Room
logger = logging.getLogger(__name__)
__all__ = ["Controller"]
class Controller:
"""
Callback order:
on_start - self.room not available
while running:
<connects to room>
on_ping - always possible (until on_disconnected)
on_bounce - self.room only session
on_hello - self.room only session
<authenticated, access to room>
on_connected - self.room session and chat room (fully connected)
on_snapshot - self.room session and chat room
<other callbacks> - self.room session and chat room
<leaving room>
on_disconnected - self.room not connected to room any more
on_stop - self.room not available
"""
def __init__(self, nick, human=False, cookie=None):
"""
roomname - name of room to connect to
human - whether the human flag should be set on connections
cookie - cookie to use in HTTP request, if any
"""
self.nick = nick
self.human = human
self.cookie = cookie
self.roomname = "test"
self.password = None
self.room = None
self._connect_result = None
def _create_room(self, roomname):
return Room(roomname, self, human=self.human, cookie=self.cookie)
def _set_connect_result(self, result):
logger.debug(f"Attempting to set connect result to {result}")
if self._connect_result and not self._connect_result.done():
logger.debug(f"Setting connect result to {result}")
self._connect_result.set_result(result)
async def connect(self, roomname, password=None, timeout=10):
"""
task, reason = await connect(roomname, password=None, timeout=10)
Connect to a room and authenticate, if necessary.
roomname - name of the room to connect to
password - password for the room, if needed
timeout - wait this long for a reply from the server
Returns:
task - the task running the bot, or None on failure
reason - the reason for failure
"no room" = could not establish connection, room doesn't exist
"auth option" = can't authenticate with a password
"no password" = password needed to connect to room
"wrong password" = password given does not work
"disconnected" = connection closed before client could access the room
"success" = no failure
"""
logger.info(f"Attempting to connect to &{roomname}")
# make sure nothing is running any more
try:
await self.stop()
except asyncio.CancelledError:
logger.error("Calling connect from the controller itself.")
raise
self.password = password
self.room = self._create_room(roomname)
# prepare for if connect() is successful
self._connect_result = asyncio.Future()
# attempt to connect to the room
task = await self.room.connect()
if not task:
logger.warn(f"Could not connect to &{roomname}.")
self.room = None
return None, "no room"
# connection succeeded, now we need to know whether we can log in
# wait for success/authentication/disconnect
# TODO: add a timeout
await self._connect_result
result = self._connect_result.result()
logger.debug(f"&{roomname}._connect_result: {result!r}")
# deal with result
if result == "success":
logger.info(f"Successfully connected to &{roomname}.")
return task, result
else: # not successful for some reason
logger.warn(f"Could not join &{roomname}: {result!r}")
await self.stop()
return None, result
async def stop(self):
if self.room:
logger.info(f"@{self.nick}: Stopping")
await self.room.stop()
logger.debug(f"@{self.nick}: Stopped. Deleting room")
self.room = None
async def set_nick(self, nick):
if nick != self.nick:
_, _, _, to_nick = await self.room.nick(nick)
if to_nick != nick:
logger.warn(f"&{self.room.roomname}: Could not set nick to {nick!r}, set to {to_nick!r} instead.")
async def on_connected(self):
"""
Client has successfully (re-)joined the room.
Use: Actions that are meant to happen upon (re-)connecting to a room,
such as resetting the message history.
"""
self._set_connect_result("success")
async def on_disconnected(self):
"""
Client has disconnected from the room.
This is the last time the old self.room can be accessed.
Use: Reconfigure self before next connection.
Need to store information from old room?
"""
logger.debug(f"on_disconnected: self.room is {self.room}")
self._set_connect_result("disconnected")
async def on_bounce(self, reason=None, auth_options=[], agent_id=None, ip=None):
if "passcode" not in auth_options:
self._set_connect_result("auth option")
elif self.password is None:
self._set_connect_result("no password")
else:
success, reason = await self.room.auth("passcode", passcode=self.password)
if not success:
self._set_connect_result("wrong password")
async def on_disconnect(self, reason):
pass
async def on_hello(self, user_id, session, room_is_private, version, account=None,
account_has_access=None, account_email_verified=None):
pass
async def on_join(self, session):
pass
async def on_login(self, account_id):
pass
async def on_logout(self):
pass
async def on_network(self, ntype, server_id, server_era):
pass
async def on_nick(self, session_id, user_id, from_nick, to_nick):
pass
async def on_edit_message(self, edit_id, message):
pass
async def on_part(self, session):
pass
async def on_ping(self, ptime, pnext):
"""
Default implementation, refer to api.euphoria.io
"""
logger.debug(f"&{self.room.roomname}: Pong!")
await self.room.ping_reply(ptime)
async def on_pm_initiate(self, from_id, from_nick, from_room, pm_id):
pass
async def on_send(self, message):
pass
async def on_snapshot(self, user_id, session_id, version, listing, log, nick=None,
pm_with_nick=None, pm_with_user_id=None):
if nick != self.nick:
await self.room.nick(self.nick)

View file

@ -1,41 +0,0 @@
class YaboliException(Exception):
"""
Generic yaboli exception class.
"""
pass
class BotManagerException(YaboliException):
"""
Generic BotManager exception class.
"""
pass
class CreateBotException(BotManagerException):
"""
This exception will be raised when BotManager could not create a bot.
"""
pass
class BotNotFoundException(BotManagerException):
"""
This exception will be raised when BotManager could not find a bot.
"""
pass
class BotException(YaboliException):
"""
Generic Bot exception class.
"""
pass
class ParseMessageException(BotException):
"""
This exception will be raised when a failure parsing a message occurs.
"""
pass

View file

@ -1,99 +0,0 @@
import time
from . import session
class Message():
"""
This class keeps track of message details.
"""
def __init__(self, id, time, sender, content, parent=None, edited=None, deleted=None,
truncated=None):
"""
id - message id
time - time the message was sent (epoch)
sender - session of the sender
content - content of the message
parent - id of the parent message, or None
edited - time of last edit (epoch)
deleted - time of deletion (epoch)
truncated - message was truncated
"""
self.id = id
self.time = time
self.sender = sender
self.content = content
self.parent = parent
self.edited = edited
self.deleted = deleted
self.truncated = truncated
@classmethod
def from_data(self, data):
"""
Creates and returns a message created from the data.
NOTE: This also creates a session object using the data in "sender".
data - a euphoria message: http://api.euphoria.io/#message
"""
sender = session.Session.from_data(data["sender"])
parent = data["parent"] if "parent" in data else None
edited = data["edited"] if "edited" in data else None
deleted = data["deleted"] if "deleted" in data else None
truncated = data["truncated"] if "truncated" in data else None
return self(
data["id"],
data["time"],
sender,
data["content"],
parent=parent,
edited=edited,
deleted=deleted,
truncated=truncated
)
def time_formatted(self, date=False):
"""
time_formatted(date=False) -> str
date - include date in format
Time in a readable format:
With date: YYYY-MM-DD HH:MM:SS
Without date: HH:MM:SS
"""
if date:
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(self.time))
else:
return time.strftime("%H:%M:%S", time.gmtime(self.time))
def is_edited(self):
"""
is_edited() -> bool
Has this message been edited?
"""
return True if self.edited is not None else False
def is_deleted(self):
"""
is_deleted() -> bool
Has this message been deleted?
"""
return True if self.deleted is not None else False
def is_truncated(self):
"""
is_truncated() -> bool
Has this message been truncated?
"""
return True if self.truncated is not None else False

View file

@ -1,154 +0,0 @@
import operator
from . import message
class Messages():
"""
Message storage class which preserves thread hierarchy.
"""
def __init__(self, message_limit=500):
"""
message_limit - maximum amount of messages that will be stored at a time
None - no limit
"""
self.message_limit = message_limit
self._by_id = {}
self._by_parent = {}
def _sort(self, msgs):
"""
_sort(messages) -> None
Sorts a list of messages by their id, in place.
"""
msgs.sort(key=operator.attrgetter("id"))
def add_from_data(self, data):
"""
add_from_data(data) -> None
Create a message from "raw" data and add it.
"""
mes = message.Message.from_data(data)
self.add(mes)
def add(self, mes):
"""
add(message) -> None
Add a message to the structure.
"""
self.remove(mes.id)
self._by_id[mes.id] = mes
if mes.parent:
if not mes.parent in self._by_parent:
self._by_parent[mes.parent] = []
self._by_parent[mes.parent].append(mes)
if self.message_limit and len(self._by_id) > self.message_limit:
self.remove(self.get_oldest().id)
def remove(self, mid):
"""
remove(message_id) -> None
Remove a message from the structure.
"""
mes = self.get(mid)
if mes:
if mes.id in self._by_id:
self._by_id.pop(mes.id)
parent = self.get_parent(mes.id)
if parent and mes in self.get_children(parent.id):
self._by_parent[mes.parent].remove(mes)
def remove_all(self):
"""
remove_all() -> None
Removes all messages.
"""
self._by_id = {}
self._by_parent = {}
def get(self, mid):
"""
get(message_id) -> Message
Returns the message with the given id, if found.
"""
if mid in self._by_id:
return self._by_id[mid]
def get_oldest(self):
"""
get_oldest() -> Message
Returns the oldest message, if found.
"""
oldest = None
for mid in self._by_id:
if oldest is None or mid < oldest:
oldest = mid
return self.get(oldest)
def get_youngest(self):
"""
get_youngest() -> Message
Returns the youngest message, if found.
"""
youngest = None
for mid in self._by_id:
if youngest is None or mid > youngest:
youngest = mid
return self.get(youngest)
def get_parent(self, mid):
"""
get_parent(message_id) -> str
Returns the message's parent, if found.
"""
mes = self.get(mid)
if mes:
return self.get(mes.parent)
def get_children(self, mid):
"""
get_children(message_id) -> list
Returns a sorted list of children of the given message, if found.
"""
if mid in self._by_parent:
children = self._by_parent[mid][:]
self._sort(children)
return children
def get_top_level(self):
"""
get_top_level() -> list
Returns a sorted list of top-level messages.
"""
msgs = [self.get(mid) for mid in self._by_id if not self.get(mid).parent]
self._sort(msgs)
return msgs

File diff suppressed because it is too large Load diff

View file

@ -1,71 +0,0 @@
class Session():
"""
This class keeps track of session details.
"""
def __init__(self, id, name, server_id, server_era, session_id, is_staff=None, is_manager=None):
"""
id - agent/account id
name - name of the client when the SessionView was captured
server_id - id of the server
server_era - era of the server
session_id - session id (unique across euphoria)
is_staff - client is staff
is_manager - client is manager
"""
self.id = id
self.name = name
self.server_id = server_id
self.server_era = server_era
self.session_id = session_id
self.staff = is_staff
self.manager = is_manager
@classmethod
def from_data(self, data):
"""
Creates and returns a session created from the data.
data - a euphoria SessionView: http://api.euphoria.io/#sessionview
"""
is_staff = data["is_staff"] if "is_staff" in data else None
is_manager = data["is_manager"] if "is_manager" in data else None
return self(
data["id"],
data["name"],
data["server_id"],
data["server_era"],
data["session_id"],
is_staff,
is_manager
)
def session_type(self):
"""
session_type() -> str
The session's type (bot, account, agent).
"""
return self.id.split(":")[0]
def is_staff(self):
"""
is_staff() -> bool
Is a user staff?
"""
return self.staff and True or False
def is_manager(self):
"""
is_manager() -> bool
Is a user manager?
"""
return self.staff and True or False

View file

@ -1,146 +0,0 @@
from . import session
class Sessions():
"""
Keeps track of sessions.
"""
def __init__(self):
"""
TODO
"""
self._sessions = {}
def add_from_data(self, data):
"""
add_raw(data) -> None
Create a session from "raw" data and add it.
"""
ses = session.Session.from_data(data)
self._sessions[ses.session_id] = ses
def add(self, ses):
"""
add(session) -> None
Add a session.
"""
self._sessions[ses.session_id] = ses
def get(self, sid):
"""
get(session_id) -> Session
Returns the session with that id.
"""
if sid in self._sessions:
return self._sessions[sid]
def remove(self, sid):
"""
remove(session) -> None
Remove a session.
"""
if sid in self._sessions:
self._sessions.pop(sid)
def remove_on_network_partition(self, server_id, server_era):
"""
remove_on_network_partition(server_id, server_era) -> None
Removes all sessions matching the server_id/server_era combo.
http://api.euphoria.io/#network-event
"""
sesnew = {}
for sid in self._sessions:
ses = self.get(sid)
if not (ses.server_id == server_id and ses.server_era == server_era):
sesnew[sid] = ses
self._sessions = sesnew
def remove_all(self):
"""
remove_all() -> None
Removes all sessions.
"""
self._sessions = {}
def get_all(self):
"""
get_all() -> list
Returns the full list of sessions.
"""
return [ses for sid, ses in self._sessions.items()]
def get_people(self):
"""
get_people() -> list
Returns a list of all non-bot and non-lurker sessions.
"""
# not a list comprehension because that would span several lines too
people = []
for sid in self._sessions:
ses = self.get(sid)
if ses.session_type() in ["agent", "account"] and ses.name:
people.append(ses)
return people
def _get_by_type(self, tp):
"""
_get_by_type(session_type) -> list
Returns a list of all non-lurker sessions with that type.
"""
return [ses for sid, ses in self._sessions.items()
if ses.session_type() == tp and ses.name]
def get_accounts(self):
"""
get_accounts() -> list
Returns a list of all logged-in sessions.
"""
return self._get_by_type("account")
def get_agents(self):
"""
get_agents() -> list
Returns a list of all sessions who are not signed into an account and not bots or lurkers.
"""
return self._get_by_type("agent")
def get_bots(self):
"""
get_bots() -> list
Returns a list of all bot sessions.
"""
return self._get_by_type("bot")
def get_lurkers(self):
"""
get_lurkers() -> list
Returns a list of all lurker sessions.
"""
return [ses for sid, ses in self._sessions.items() if not ses.name]

175
yaboli/utils.py Normal file
View file

@ -0,0 +1,175 @@
import asyncio
import time
__all__ = [
"run_controller",
"mention", "mention_reduced", "similar",
"format_time", "format_time_delta",
"Session", "Listing",
"Message", "Log",
"ResponseError"
]
def run_controller(controller, room):
"""
Helper function to run a singular controller.
"""
async def run():
task, reason = await controller.connect(room)
if task:
await task
asyncio.get_event_loop().run_until_complete(run())
def mention(nick):
return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace())
def mention_reduced(nick):
return mention(nick).lower()
def similar(nick1, nick2):
return mention_reduced(nick1) == mention_reduced(nick2)
def format_time(timestamp):
return time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
time.gmtime(timestamp)
)
def format_time_delta(delta):
if delta < 0:
result = "-"
else:
result = ""
delta = int(delta)
second = 1
minute = second*60
hour = minute*60
day = hour*24
if delta >= day:
result += f"{delta//day}d "
delta = delta%day
if delta >= hour:
result += f"{delta//hour}h "
delta = delta%day
if delta >= minute:
result += f"{delta//minute}m "
delta = delta%minute
result += f"{delta}s"
return result
class Session:
def __init__(self, user_id, nick, server_id, server_era, session_id, is_staff=None,
is_manager=None, client_address=None, real_address=None):
self.user_id = user_id
self.nick = nick
self.server_id = server_id
self.server_era = server_era
self.session_id = session_id
self.is_staff = is_staff
self.is_manager = is_manager
self.client_address = client_address
self.real_address = real_address
@classmethod
def from_dict(cls, d):
return cls(
d.get("id"),
d.get("name"),
d.get("server_id"),
d.get("server_era"),
d.get("session_id"),
d.get("is_staff", None),
d.get("is_manager", None),
d.get("client_address", None),
d.get("real_address", None)
)
@property
def client_type(self):
# account, agent or bot
return self.user_id.split(":")[0]
class Listing:
def __init__(self):
self._sessions = {}
def __len__(self):
return len(self._sessions)
def add(self, session):
self._sessions[session.session_id] = session
def remove(self, session_id):
self._sessions.pop(session_id)
def remove_combo(self, server_id, server_era):
self._sessions = {i: ses for i, ses in self._sessions.items
if ses.server_id != server_id and ses.server_era != server_era}
def by_sid(self, session_id):
return self._sessions.get(session_id);
def by_uid(self, user_id):
return [ses for ses in self._sessions if ses.user_id == user_id]
def get_people(self):
return {uid: ses for uid, ses in self._sessions.items()
if ses.client_type in ["agent", "account"]}
def get_accounts(self):
return {uid: ses for uid, ses in self._sessions.items()
if ses.client_type is "account"}
def get_agents(self):
return {uid: ses for uid, ses in self._sessions.items()
if ses.client_type is "agent"}
def get_bots(self):
return {uid: ses for uid, ses in self._sessions.items()
if ses.client_type is "bot"}
class Message():
def __init__(self, message_id, time, sender, content, parent=None, previous_edit_id=None,
encryption_key=None, edited=None, deleted=None, truncated=None):
self.message_id = message_id
self.time = time
self.sender = sender
self.content = content
self.parent = parent
self.previous_edit_id = previous_edit_id
self.encryption_key = encryption_key
self.edited = edited
self.deleted = deleted
self.truncated = truncated
@classmethod
def from_dict(cls, d):
return cls(
d.get("id"),
d.get("time"),
Session.from_dict(d.get("sender")),
d.get("content"),
d.get("parent", None),
d.get("previous_edit_id", None),
d.get("encryption_key", None),
d.get("edited", None),
d.get("deleted", None),
d.get("truncated", None)
)
class Log:
pass # TODO
class ResponseError(Exception):
pass