Make bot library functional

This commit is contained in:
Joscha 2018-07-26 19:54:44 +00:00
parent 3eade77cf1
commit 1bb38fc836
9 changed files with 167 additions and 498 deletions

21
ExampleBot.py Normal file
View file

@ -0,0 +1,21 @@
import asyncio
import yaboli
class ExampleBot(yaboli.Bot):
async def send(self, room, message):
ping = "ExamplePong!"
short_help = "Example bot for the yaboli bot library"
long_help = "I'm an example bot for the yaboli bot library, which can be found at https://github.com/Garmelon/yaboli"
await self.botrulez_ping_general(room, message, ping_text=ping)
await self.botrulez_ping_specific(room, message, ping_text=ping)
await self.botrulez_help_general(room, message, help_text=short_help)
await self.botrulez_help_specific(room, message, help_text=long_help)
await self.botrulez_uptime(room, message)
await self.botrulez_kill(room, message)
await self.botrulez_restart(room, message)
forward = send # should work without modifications for most bots
bot = ExampleBot("ExampleBot", "examplebot_cookies", rooms=["test", "welcome"])
asyncio.get_event_loop().run_forever()

View file

@ -1,10 +0,0 @@
from setuptools import setup
setup(name='yaboli',
version='1.0',
description='Yet Another BOt LIbrary for euphoria.io',
author='Garmelon',
url='https://github.com/Garmelon/yaboli',
packages=['yaboli'],
install_requires=['websockets']
)

View file

@ -10,6 +10,7 @@ logging.getLogger("asyncio").setLevel(logging.DEBUG)
logging.getLogger(__name__).setLevel(logging.DEBUG)
# ----------- END DEV SECTION -----------
from .bot import *
from .cookiejar import *
from .connection import *
from .exceptions import *
@ -17,6 +18,7 @@ from .room import *
from .utils import *
__all__ = (
bot.__all__ +
connection.__all__ +
cookiejar.__all__ +
exceptions.__all__ +

View file

@ -1,166 +1,110 @@
import asyncio
from collections import namedtuple
import logging
import re
import time
from .callbacks import *
from .controller import *
from .cookiejar import *
from .room import *
from .utils import *
logger = logging.getLogger(__name__)
__all__ = ["Bot"]
# Some command stuff
class Bot(Controller):
# ^ and $ not needed since we're doing a re.fullmatch
SPECIFIC_RE = r"!(\S+)\s+@(\S+)([\S\s]*)"
GENERIC_RE = r"!(\S+)([\S\s]*)"
SPECIFIC_RE = re.compile(r"!(\S+)\s+@(\S+)\s*([\S\s]*)")
GENERAL_RE = re.compile(r"!(\S+)\s*([\S\s]*)")
ParsedMessage = namedtuple("ParsedMessage", ["command", "argstr"])
TopicHelp = namedtuple("TopicHelp", ["text", "visible"])
def __init__(self, nick):
super().__init__(nick)
self.restarting = False # whoever runs the bot can check if a restart is necessary
self.start_time = time.time()
self._commands = Callbacks()
self._triggers = Callbacks()
self.register_default_commands()
self._help_topics = {}
self.add_default_help_topics()
# settings (modify in your bot's __init__)
self.help_general = None # None -> does not respond to general help
self.help_specific = "No help available"
self.killable = True
self.kill_message = "/me *poof*" # how to respond to !kill, whether killable or not
self.restartable = True
self.restart_message = "/me temporary *poof*" # how to respond to !restart, whether restartable or not
self.ping_message = "Pong!" # as specified by the botrulez
def register_command(self, command, callback, specific=True):
self._commands.add((command, specific), callback)
def register_trigger(self, regex, callback):
self._triggers.add(re.compile(regex), callback)
def register_trigger_compiled(self, comp_regex, callback):
self._triggers.add(comp_regex, callback)
def add_help(self, topic, text, visible=True):
info = self.TopicHelp(text, visible)
self._help_topics[topic] = info
def get_help(self, topic):
info = self._help_topics.get(topic, None)
if info:
return self.format_help(info.text)
def format_help(self, helptext):
return helptext.format(
nick=mention(self.nick)
)
def list_help_topics(self, max_characters=100):
# Magic happens here to ensure that the resulting lines are always
# max_characters or less characters long.
lines = []
curline = ""
wrapper = None
for topic, info in sorted(self._help_topics.items()):
if not info.visible:
continue
if wrapper:
curline += ","
lines.append(curline)
curline = wrapper
wrapper = None
if not curline:
curline = topic
elif len(curline) + len(f", {topic},") <= max_characters:
curline += f", {topic}"
elif len(curline) + len(f", {topic}") <= max_characters:
wrapper = topic
def command(commandname, specific=True, noargs=False):
def decorator(func):
async def wrapper(self, room, message, *args, **kwargs):
print(f"New message: {message.content!r}, current name: {room.session!r}")
if specific:
print(f"Trying specific: {message.content!r}")
result = self._parse_command(message.content, specific=room.session.nick)
else:
curline += ","
lines.append(curline)
curline = topic
if wrapper:
curline += ","
lines.append(curline)
lines.append(wrapper)
elif curline:
lines.append(curline)
return "\n".join(lines)
async def restart(self):
# After calling this, the bot is stopped, not yet restarted.
self.restarting = True
await self.stop()
def noargs(func):
async def wrapper(self, message, argstr):
if not argstr:
return await func(self, message)
print(f"Trying general: {message.content!r}")
result = self._parse_command(message.content)
if result is None: return
cmd, argstr = result
if cmd != commandname: return
if noargs:
if argstr: return
return await func(self, room, message, *args, **kwargs)
else:
return await func(self, room, message, args*args, **kwargs)
return wrapper
return decorator
async def on_send(self, message):
wait = []
# get specific command to call (if any)
specific = self.parse_message(message.content, specific=True)
if specific:
wait.append(self._commands.call(
(specific.command, True),
message, specific.argstr
))
# And now comes the real bot...
# get generic command to call (if any)
general = self.parse_message(message.content, specific=False)
if general:
wait.append(self._commands.call(
(general.command, False),
message, general.argstr
))
class Bot(Inhabitant):
def __init__(self, nick, cookiefile=None, rooms=["test"]):
self.target_nick = nick
self.rooms = {}
self.cookiejar = CookieJar(cookiefile)
# find triggers to call (if any)
for trigger in self._triggers.list():
match = trigger.fullmatch(message.content)
if match:
wait.append(self._triggers.call(trigger, message, match))
for roomname in rooms:
self.join_room(roomname)
if wait:
await asyncio.wait(wait)
# ROOM MANAGEMENT
def parse_message(self, content, specific=True):
def join_room(self, roomname, password=None):
if roomname in self.rooms:
return
self.rooms[roomname] = Room(self, roomname, self.target_nick, password=password, cookiejar=self.cookiejar)
async def part_room(self, roomname):
room = self.rooms.pop(roomname, None)
if room:
await room.exit()
# BOTRULEZ
@command("ping", specific=False, noargs=True)
async def botrulez_ping_general(self, room, message, ping_text="Pong!"):
await room.send(ping_text, message.mid)
@command("ping", specific=True, noargs=True)
async def botrulez_ping_specific(self, room, message, ping_text="Pong!"):
await room.send(ping_text, message.mid)
@command("help", specific=False, noargs=True)
async def botrulez_help_general(self, room, message, help_text="Placeholder help text"):
await room.send(help_text, message.mid)
@command("help", specific=True, noargs=True)
async def botrulez_help_specific(self, room, message, help_text="Placeholder help text"):
await room.send(help_text, message.mid)
@command("uptime", specific=True, noargs=True)
async def botrulez_uptime(self, room, message):
now = time.time()
startformat = format_time(room.start_time)
deltaformat = format_time_delta(now - room.start_time)
text = f"/me has been up since {startformat} ({deltaformat})"
await room.send(text, message.mid)
@command("kill", specific=True, noargs=True)
async def botrulez_kill(self, room, message, kill_text="/me dies"):
await room.send(kill_text, message.mid)
await self.part_room(room.roomname)
@command("restart", specific=True, noargs=True)
async def botrulez_restart(self, room, message, restart_text="/me restarts"):
await room.send(restart_text, message.mid)
await self.part_room(room.roomname)
self.join_room(room.roomname, password=room.password)
# COMMAND PARSING
@staticmethod
def parse_args(text):
"""
ParsedMessage = parse_message(content)
Returns None, not a (None, None) tuple, when message could not be parsed
"""
if specific:
match = re.fullmatch(self.SPECIFIC_RE, content)
if match and similar(match.group(2), self.nick):
return self.ParsedMessage(match.group(1), match.group(3))
else:
match = re.fullmatch(self.GENERIC_RE, content)
if match:
return self.ParsedMessage(match.group(1), match.group(2))
def parse_args(self, text):
"""
Use single- and double-quotes bash-style to include whitespace in arguments.
Use bash-style single- and double-quotes to include whitespace in arguments.
A backslash always escapes the next character.
Any non-escaped whitespace separates arguments.
@ -201,7 +145,8 @@ class Bot(Controller):
return args
def parse_flags(self, arglist):
@staticmethod
def parse_flags(arglist):
flags = ""
args = []
kwargs = {}
@ -225,109 +170,16 @@ class Bot(Controller):
return flags, args, kwargs
# BOTRULEZ AND YABOLI-SPECIFIC COMMANDS
def register_default_commands(self):
self.register_command("ping", self.command_ping)
self.register_command("ping", self.command_ping, specific=False)
self.register_command("help", self.command_help)
self.register_command("help", self.command_help_general, specific=False)
self.register_command("uptime", self.command_uptime)
self.register_command("kill", self.command_kill)
self.register_command("restart", self.command_restart)
def add_default_help_topics(self):
self.add_help("botrulez", (
"This bot complies with the botrulez at https://github.com/jedevc/botrulez.\n"
"It implements the standard commands, and additionally !kill and !restart.\n\n"
"Standard commands:\n"
" !ping, !ping @{nick} - reply with a short pong message\n"
" !help, !help @{nick} - reply with help about the bot\n"
" !uptime @{nick} - reply with the bot's uptime\n\n"
"Non-standard commands:\n"
" !kill @{nick} - terminate this bot instance\n"
" !restart @{nick} - restart this bot instance\n\n"
"Command extensions:\n"
" !help @{nick} <topic> [<topic> ...] - provide help on the topics listed"
))
self.add_help("yaboli", (
"Yaboli is \"Yet Another BOt LIbrary for euphoria\", written by @Garmy in Python.\n"
"It relies heavily on the asyncio module from the standard library and uses f-strings.\n"
"Because of this, Python version >= 3.6 is required.\n\n"
"Github: https://github.com/Garmelon/yaboli"
))
@noargs
async def command_ping(self, message):
if self.ping_message:
await self.room.send(self.ping_message, message.mid)
async def command_help(self, message, argstr):
args = self.parse_args(argstr.lower())
if not args:
if self.help_specific:
await self.room.send(
self.format_help(self.help_specific),
message.mid
)
@staticmethod
def _parse_command(content, specific=None):
print(repr(specific))
if specific is not None:
print("SPECIFIC")
match = SPECIFIC_RE.fullmatch(content)
if match and similar(match.group(2), specific):
return match.group(1), match.group(3)
else:
# collect all valid topics
messages = []
for topic in sorted(set(args)):
text = self.get_help(topic)
if text:
messages.append(f"Topic: {topic}\n{text}")
# print result in separate messages
if messages:
for text in messages:
await self.room.send(text, message.mid)
else:
await self.room.send("None of those topics found.", message.mid)
@noargs
async def command_help_general(self, message):
if self.help_general is not None:
await self.room.send(self.help_general, message.mid)
@noargs
async def command_uptime(self, message):
now = time.time()
startformat = format_time(self.start_time)
deltaformat = format_time_delta(now - self.start_time)
text = f"/me has been up since {startformat} ({deltaformat})"
await self.room.send(text, message.mid)
async def command_kill(self, message, args):
logging.warn(f"Kill attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}")
if self.kill_message is not None:
await self.room.send(self.kill_message, message.mid)
if self.killable:
await self.stop()
async def command_restart(self, message, args):
logging.warn(f"Restart attempt by @{mention(message.sender.nick)} in &{self.room.roomname}: {message.content!r}")
if self.restart_message is not None:
await self.room.send(self.restart_message, message.mid)
if self.restartable:
await self.restart()
class Multibot(Bot):
def __init__(self, nick, keeper):
super().__init__(nick)
self.keeper = keeper
class MultibotKeeper():
def __init__(self, configfile):
# TODO: load configfile
# TODO: namedtuple botinfo (bot, task)
self._bots = {} # self._bots[roomname] = botinfo
print("GENERAL")
match = GENERAL_RE.fullmatch(content)
if match:
return match.group(1), match.group(2)

View file

@ -109,6 +109,7 @@ class Connection:
if self.cookiejar:
for set_cookie in self._ws.response_headers.get_all("Set-Cookie"):
self.cookiejar.bake(set_cookie)
self.cookiejar.save()
self._pingtask = asyncio.ensure_future(self._ping())

View file

@ -1,213 +0,0 @@
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, connect_timeout=10):
"""
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
connect_timeout - time for authentication to complete
"""
self.nick = nick
self.human = human
self.cookie = cookie
self.roomname = "test"
self.password = None
self.room = None
self.connect_timeout = connect_timeout # in seconds
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
"timeout" = timed out while waiting for server
"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
try:
await asyncio.wait_for(self._connect_result, self.connect_timeout)
except asyncio.TimeoutError:
result = "timeout"
else:
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)
self.nick = to_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, sessions, messages, nick=None,
pm_with_nick=None, pm_with_user_id=None):
if nick != self.nick:
await self.room.nick(self.nick)

View file

@ -12,10 +12,14 @@ class CookieJar:
Keeps your cookies in a file.
"""
def __init__(self, filename):
def __init__(self, filename=None):
self._filename = filename
self._cookies = cookies.SimpleCookie()
if not self._filename:
logger.info("Could not load cookies, no filename given.")
return
with contextlib.suppress(FileNotFoundError):
with open(self._filename, "r") as f:
for line in f:
@ -45,6 +49,10 @@ class CookieJar:
Saves all current cookies to the cookie jar file.
"""
if not self._filename:
logger.info("Could not save cookies, no filename given.")
return
logger.debug(f"Saving cookies to {self._filename!r}")
with open(self._filename, "w") as f:

View file

@ -1,5 +1,6 @@
import asyncio
import logging
import time
from .connection import *
from .exceptions import *
@ -19,12 +20,13 @@ class Room:
DISCONNECTED = 2
CLOSED = 3
def __init__(self, roomname, inhabitant, password=None, human=False, cookiejar=None):
def __init__(self, inhabitant, roomname, nick, password=None, human=False, cookiejar=None):
# TODO: Connect to room etc.
# TODO: Deal with room/connection states of:
# disconnected connecting, fast-forwarding, connected
# Room info (all fields readonly!)
self.target_nick = nick
self.roomname = roomname
self.password = password
self.human = human
@ -33,6 +35,8 @@ class Room:
self.account = None
self.listing = Listing()
self.start_time = time.time()
self.account_has_access = None
self.account_email_verified = None
self.room_is_private = None
@ -103,6 +107,8 @@ class Room:
uid = data.get("id")
from_nick = data.get("from")
to_nick = data.get("to")
self.session.nick = to_nick
return sid, uid, from_nick, to_nick
async def pm(self, uid):
@ -260,15 +266,13 @@ class Room:
# Update room info
self.pm_with_nick = data.get("pm_with_nick", None),
self.pm_with_user_id = data.get("pm_with_user_id", None)
self.session.nick = data.get("nick", None)
# Remember old nick, because we're going to try to get it back
old_nick = self.session.nick if self.session else None
new_nick = data.get("nick", None)
self.session.nick = new_nick
if old_nick and old_nick != new_nick:
# Make sure a room is not CONNECTED without a nick
if self.target_nick and self.target_nick != self.session.nick:
try:
await self._connection.send("nick", data={"name": old_nick})
_, nick_data, _, _ = await self._connection.send("nick", data={"name": self.target_nick})
self.session.nick = nick_data.get("to")
except ConnectionClosed:
return # Aww, we've lost connection again

View file

@ -2,12 +2,16 @@ import asyncio
import time
__all__ = [
"parallel",
"mention", "mention_reduced", "similar",
"format_time", "format_time_delta",
"Session", "Listing", "Message",
]
# alias for parallel message sending
parallel = asyncio.ensure_future
def mention(nick):
return "".join(c for c in nick if c not in ".!?;&<'\"" and not c.isspace())