Use discord.py 1.5.1.
authorCMDR furrycat <elite@furrycat.net>
Wed, 11 Nov 2020 08:26:30 +0000 (09:26 +0100)
committerCMDR furrycat <elite@furrycat.net>
Wed, 11 Nov 2020 08:26:30 +0000 (09:26 +0100)
Application code and all plugins migrated to the new API.
Startup now respects CATBOT_SHARD_ID and CATBOT_SHARD_COUNT environment
variables.
Typing context managers can be shared between message send invocations.

37 files changed:
app.py
bot.py
cat.py
db.py
plugins/__init__.py [moved from plugins.py with 93% similarity]
plugins/admin/__init__.py [moved from plugin/admin/admin.py with 98% similarity]
plugins/affection/__init__.py [moved from plugin/affection/affection.py with 68% similarity]
plugins/announcements/__init__.py [moved from plugin/announcements/announcements.py with 97% similarity]
plugins/bling/__init__.py [moved from plugin/bling/bling.py with 88% similarity]
plugins/bling/bucky/bg.png [moved from plugin/bling/bucky/bg.png with 100% similarity]
plugins/bling/bucky/fg.png [moved from plugin/bling/bucky/fg.png with 100% similarity]
plugins/bling/bucky/mask.png [moved from plugin/bling/bucky/mask.png with 100% similarity]
plugins/bling/default [moved from plugin/bling/default with 100% similarity]
plugins/bling/delta/bg.png [moved from plugin/bling/delta/bg.png with 100% similarity]
plugins/bling/delta/fg.png [moved from plugin/bling/delta/fg.png with 100% similarity]
plugins/bling/delta/mask.png [moved from plugin/bling/delta/mask.png with 100% similarity]
plugins/bling/newton/bg.png [moved from plugin/bling/newton/bg.png with 100% similarity]
plugins/bling/newton/fg.png [moved from plugin/bling/newton/fg.png with 100% similarity]
plugins/bling/newton/mask.png [moved from plugin/bling/newton/mask.png with 100% similarity]
plugins/developer/__init__.py [moved from plugin/developer/developer.py with 74% similarity]
plugins/edts/__init__.py [moved from plugin/edts/__init__.py with 92% similarity]
plugins/faction/__init__.py [moved from plugin/faction/faction.py with 97% similarity]
plugins/facts/cat_haiku.txt [moved from plugin/facts/cat_haiku.txt with 100% similarity]
plugins/facts/catfacts.txt [moved from plugin/facts/catfacts.txt with 100% similarity]
plugins/facts/facts.py [moved from plugin/facts/facts.py with 95% similarity]
plugins/feeds/__init__.py [moved from plugin/feeds/feeds.py with 95% similarity]
plugins/greetings/__init__.py [moved from plugin/greetings/greetings.py with 93% similarity]
plugins/heading/__init__.py [moved from plugin/heading/heading.py with 74% similarity]
plugins/mention_hint/__init__.py [moved from plugin/mention_hint/mention_hint.py with 63% similarity]
plugins/o7/__init__.py [moved from plugin/o7/o7.py with 100% similarity]
plugins/play/__init__.py [moved from plugin/play/play.py with 78% similarity]
plugins/prune/__init__.py [moved from plugin/prune/__init__.py with 98% similarity]
plugins/rng/__init__.py [moved from plugin/rng/rng.py with 100% similarity]
plugins/role/__init__.py [moved from plugin/role/role.py with 88% similarity]
plugins/shopping/__init__.py [moved from plugin/shopping/shopping.py with 85% similarity]
plugins/typing/__init__.py [moved from plugin/typing/typing.py with 90% similarity]
requirements.txt

diff --git a/app.py b/app.py
index eee2a72..ebee67f 100644 (file)
--- a/app.py
+++ b/app.py
@@ -2,7 +2,7 @@
 
 import asyncio
 import datetime
-import discord_unofficial as discord
+import discord
 import logging
 import multiprocessing
 import os
@@ -39,19 +39,36 @@ for logger in ['catbot', 'eddb', 'eddn']:
 plugins = Plugins(log.level)
 
 idletime = multiprocessing.Value('i', 900)
+discord_ready = multiprocessing.Value('b', False)
 threads_ready = multiprocessing.Value('b', False)
 
-async def say_expect(message, stdout):
+value = os.getenv('CATBOT_SHARD_ID')
+if value:
+  try:
+    shard_id = int(value)
+  except:
+    log.error('Invalid shard ID: {}'.format(value))
+    sys.exit(1)
+
+value = os.getenv('CATBOT_SHARD_COUNT')
+if value:
+  try:
+    shard_count = int(value)
+  except:
+    log.error('Invalid shard count: {}'.format(value))
+    sys.exit(1)
+
+async def say_expect(message, stdout, typing = None):
   if stdout:
     log.debug(stdout)
-    result = await bot.say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not message.channel.is_private else '', stdout))
+    result = await bot.say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not bot.is_private(message.channel) else '', stdout), typing = typing)
   else:
-    result = await bot.say(message.channel, 'Sorry!')
+    result = await bot.say(message.channel, 'Sorry!', typing = typing)
   return result
 
-async def not_admin(message):
+async def not_admin(message, **args):
   log.warning('Not admin: {} id {}'.format(message.author, message.author.id))
-  await bot.maybe_say(message.channel, 'hiss!')
+  await bot.maybe_say(message.channel, 'hiss!', **args)
 
 async def show_help(message, *args):
   if len(args):
@@ -63,7 +80,7 @@ async def show_help(message, *args):
     valid_commands = ['id', 'stop playing', 'time', 'url']
     valid_commands += plugins.valid_commands()
     lines = [
-      '<@{}>, the cat-like robot from the 34th century of the future, recognises these commands (and more):'.format(client.user.id),
+      'Version 2 of <@{}>, the cat-like robot from the 34th century of the future, recognises these commands (and more):'.format(client.user.id),
       '```'
     ]
     lines += sorted(valid_commands)
@@ -93,7 +110,7 @@ async def show_help(message, *args):
 
 async def do_commands(message, command, raw, non_admin):
   if not non_admin:
-    if not bot.is_admin(message.author, message.server):
+    if not bot.is_admin(message.author, message.guild):
       await not_admin(message)
       return
 
@@ -118,7 +135,7 @@ async def do_commands(message, command, raw, non_admin):
 
   elif command == 'delete':
     # delete
-    if message.channel.is_private:
+    if bot.is_private(message.channel):
       await cat.hiss(message.channel, wake = False)
       return
 
@@ -128,7 +145,7 @@ async def do_commands(message, command, raw, non_admin):
     if m is not None:
       if m.group(1) != 'all':
         limit = int(m.group(1))
-    await client.purge_from(message.channel, *args, limit = limit)
+    await message.channel.purge(*args, limit = limit)
     return
 
   elif command == 'help':
@@ -162,7 +179,7 @@ async def do_commands(message, command, raw, non_admin):
   elif command == 'time':
     # time
     await bot.say(message.channel, bot.iso8601(int(time.time())))
-    await bot.maybe_play_sound(bot.voice_channel_for_user(message.author), 'purr.wav', join = False)
+    await cat.maybe_purr(message.channel, voice = bot.voice_channel_for_user(message.author), join = False, wake = False)
     return
 
   elif command == 'url':
@@ -170,17 +187,16 @@ async def do_commands(message, command, raw, non_admin):
     await bot.say(message.channel, "<{}>".format(discord.utils.oauth_url(client.user.id)))
     return
 
-  await bot.maybe_say(message.channel, 'purr', wake = False)
-  await bot.maybe_play_sound(bot.voice_channel_for_user(message.author), 'purr.wav', join = False)
+  await cat.maybe_purr(message.channel, voice = bot.voice_channel_for_user(message.author), join = False, wake = False)
 
-async def do_fork(message, command, raw):
-  await client.send_typing(message.channel)
+async def do_fork(message, command, raw, typing = None):
   if re.search(r'[^A-Za-z0-9-_."\'\s]', raw):
     await cat.hiss(message.channel)
-    return
-  p = pexpect.spawnu('./{}'.format(raw))
-  p.expect(pexpect.EOF)
-  await say_expect(message, p.before)
+  else:
+    with bot.typing_context_manager(message.channel, typing) as typing:
+      p = pexpect.spawnu('./{}'.format(raw))
+      p.expect(pexpect.EOF)
+    await say_expect(message, p.before, typing = typing)
 
 async def maybe_sleep():
   while True:
@@ -191,10 +207,10 @@ async def maybe_sleep():
     await asyncio.sleep(60)
 
 async def request_offline_members():
-  for server in client.servers:
+  for server in client.guilds:
     if server.large:
       log.info('Requesting offline user list from {}'.format(server.name))
-      await client.request_offline_members(server)
+      await server.chunk()
 
 modules = {
   'admin': {
@@ -216,7 +232,11 @@ modules = {
 
 loop = eddn.init()
 asyncio.set_event_loop(loop)
-client = discord.Client()
+client = discord.Client(
+  intents = discord.Intents.all(),
+  shard_id = shard_id, shard_count = shard_count,
+  assume_unsync_clock = False
+ )
 bot.client = client
 bot.db = db
 plugins.set_client(client)
@@ -270,6 +290,8 @@ async def process_module(message, command, raw):
 
 @client.event
 async def on_ready():
+  discord_ready.value = True
+  log.info('{} can now receive commands!'.format(client.user.name))
   state = db.get_state(client)
   if state is not None:
     if state['idle']:
@@ -277,10 +299,10 @@ async def on_ready():
       await bot.set_idle(True)
       if state['avatar'] != db.IDLE_AVATAR:
         log.info('Restoring idle avatar.')
-        await set_avatar(db.IDLE_AVATAR)
+        await bot.set_avatar(db.IDLE_AVATAR)
     elif state['avatar'] != db.ONLINE_AVATAR:
       log.info('Restoring online avatar.')
-      await set_avatar(db.ONLINE_AVATAR)
+      await bot.set_avatar(db.ONLINE_AVATAR)
     if state['last_spoke']:
       bot.set('last_spoke', state['last_spoke'])
   log.info('Logged in as {}#{}'.format(client.user.name, client.user.id))
@@ -292,7 +314,7 @@ async def on_ready():
     threads_ready.value = True
 
 def mentioned_in(message, explicit = True):
-  if message.channel.is_private:
+  if bot.is_private(message.channel):
     return True
   if not client.user.mentioned_in(message):
     m = re.match('<@!{}>'.format(client.user.id), message.content)
@@ -312,7 +334,7 @@ async def on_typing(channel, user, when):
     return
   if user.bot:
     return
-  if channel.is_private:
+  if bot.is_private(channel):
     return
   log.debug('{} is typing in {}'.format(user, channel))
   await plugins.on_typing(channel, user, when)
@@ -331,12 +353,12 @@ async def on_message(message):
     return
 
   # Strip our mention from the message.
-  raw = re.sub('<@!?{}>'.format(client.user.id), '', message.content).strip()
+  raw = re.sub(r'<@!?{}>'.format(client.user.id), '', message.content).strip()
 
   # Handle copy/paste; Discord client will prepend (BOT)<user>: to pasted
   # text.  Deal with the common cases of the user copying his own message
   # or quoting the bot.
-  raw = re.sub('^(?:BOT{}|{}):\s+'.format(client.user.name, message.author.name), '', raw)
+  raw = re.sub(r'^(?:BOT{}|{}):\s+'.format(client.user.name, message.author.name), '', raw)
 
   m = re.match(r'^(\S+)', raw)
   if m is None:
@@ -357,8 +379,19 @@ async def on_member_join(member):
 async def on_member_update(before, after):
   await plugins.on_member_update(before, after)
 
+async def await_login():
+  backoff = 1
+  while not discord_ready.value:
+    if backoff > 1:
+      log.info('Waiting for Discord login...')
+    await asyncio.sleep(backoff)
+    backoff *= 2
+    if backoff > 4:
+      backoff = 4
+
 ret = 0
 try:
+  asyncio.ensure_future(await_login())
   log.info('Logging in to Discord.')
   loop.run_until_complete(client.login(token))
   log.info('Opening WebSocket.')
diff --git a/bot.py b/bot.py
index 3333d45..f66d89d 100644 (file)
--- a/bot.py
+++ b/bot.py
@@ -1,6 +1,6 @@
 import asyncio
 import datetime
-import discord_unofficial as discord
+import discord
 import hashlib
 import io
 import math
@@ -16,6 +16,8 @@ import time
 import traceback
 import urllib.request
 
+import cat
+
 variables = {
   'dryrun': multiprocessing.Value('b', os.getenv('DRYRUN') is not None),
   'debug': multiprocessing.Value('b', os.getenv('DEBUG') is not None),
@@ -25,6 +27,10 @@ variables = {
   'interrupt_playing': multiprocessing.Value('b', False)
 }
 
+def format_frame(frame):
+  dirname, basename = frame.filename.split(os.path.sep)[-2:]
+  return '{}@{}:{}'.format(frame.name, dirname if basename == '__init__.py' else basename, frame.lineno)
+
 def is_admin(user, server = None, context = None):
   ret = False
   params = ['global']
@@ -42,12 +48,12 @@ def is_admin(user, server = None, context = None):
     break
   db.close_db()
   tb = reversed(traceback.extract_stack()[0:-2])
-  log.info('{}: User {} {} an administrator{}'.format(': '.join(['{}@{}:{}'.format(frame[2], frame[0].split('/')[-1], frame[1]) for frame in tb]), user.name, 'is' if ret else 'is not', ' on server {}'.format(server.name) if server is not None else ''))
+  log.info('{}: User {} {} an administrator{}'.format(': '.join([format_frame(frame) for frame in tb]), user.name, 'is' if ret else 'is not', ' on server {}'.format(server.name) if server is not None else ''))
   return ret
 
 def is_member(member, name, *, author = None, check_nick = True):
   if author is not None:
-    if not member.server.get_member(author.id):
+    if not member.guild.get_member(author.id):
       return False
   parts = name.split('#')
   member_name = parts[0].lower()
@@ -66,6 +72,24 @@ def is_member(member, name, *, author = None, check_nick = True):
     return False
   return True
 
+def is_private(channel):
+  return isinstance(channel, discord.abc.PrivateChannel)
+
+def is_everyone(role):
+  return role.id == role.guild.roles[0].id
+
+def canonicalise(user):
+  return '{}#{}'.format(user.name, user.discriminator)
+
+def snowflake(id):
+  if id is None:
+    return None
+  try:
+    return int(id)
+  except ValueError:
+    log.error('Invalid snowflake: {}'.format(id))
+    return None
+
 def get_member_by_name(name, author = None):
   candidates = []
   for member in client.get_all_members():
@@ -145,6 +169,24 @@ def unparse_seconds(seconds):
     interval += '{}s'.format(seconds)
   return interval
 
+def get_server(id):
+  return client.get_guild(snowflake(id))
+
+def get_channel(id):
+  return client.get_channel(snowflake(id))
+
+def get_message_servers(message, allow_private = False):
+  servers = []
+  if is_private(message.channel):
+    if allow_private:
+      servers.append('private')
+    for server in client.guilds:
+      if message.author in server.members:
+        servers.append(server)
+  else:
+    servers = [message.channel.guild]
+  return servers
+
 def shared_with(user, instance, allow_admin_override = True):
   if user is None:
     return True
@@ -153,17 +195,17 @@ def shared_with(user, instance, allow_admin_override = True):
   if allow_admin_override and is_admin(user):
     return True
 
-  if type(instance) == discord.server.Server:
+  if type(instance) == discord.guild.Guild:
     servers = [instance]
   elif type(instance) in [discord.member.Member, discord.user.User]:
     servers = []
-    for server in client.servers:
+    for server in client.guilds:
       if server.get_member(instance.id):
         servers.append(server)
-  elif type(instance) == discord.channel.Channel:
-    servers = [instance.server]
+  elif isinstance(instance, discord.abc.GuildChannel):
+    servers = [instance.guild]
   elif type(instance) == discord.role.Role:
-    servers = [instance.server]
+    servers = [instance.guild]
   else:
     log.warning("Don't know how to check if a {} is known to {}!".format(type(instance), user))
     return True
@@ -193,7 +235,7 @@ def parse_server(text, *, author = None):
       if author is not None:
         log.warning("User {} isn't on parsed server {}!".format(author, server.name))
 
-  for server in client.servers:
+  for server in client.guilds:
     if server.name.lower() == text.lower():
       if shared_with(author, server):
         return server
@@ -204,6 +246,7 @@ def parse_server(text, *, author = None):
   return None
 
 def parse_channel(text, *, author = None, text_ok = True, voice_ok = True):
+  text = str(text)
   channel_type = '{}, {}'.format('text' if text_ok else 'non-text', 'voice' if voice_ok else 'non-voice')
   log.debug("Parsing {} channel {}".format(channel_type, text))
   if author is None:
@@ -223,9 +266,9 @@ def parse_channel(text, *, author = None, text_ok = True, voice_ok = True):
   if m is None:
     m = re.match(r'#?(\d+)', text)
   if m is not None:
-    channel_id = m.group(1)
+    channel_id = snowflake(m.group(1))
     log.debug('Channel ID {}.'.format(channel_id))
-    channel = client.get_channel(channel_id)
+    channel = get_channel(channel_id)
     if not channel:
       log.warning("Can't find channel with ID {}!".format(channel_id))
       return None
@@ -238,7 +281,7 @@ def parse_channel(text, *, author = None, text_ok = True, voice_ok = True):
     if shared_with(author, channel):
       return channel
     if author is not None:
-      log.warning("User {} isn't on channel {} server {}!".format(author, channel.name, server.name))
+      log.warning("User {} isn't on channel {} server {}!".format(author, channel.name, channel.guild.name))
 
   # #name or name.
   if text[0] == '#':
@@ -248,7 +291,7 @@ def parse_channel(text, *, author = None, text_ok = True, voice_ok = True):
   for channel in client.get_all_channels():
     # Note that text channels are all lowercase; voice channels are title case.
     if channel.name.lower() == name.lower():
-      log.debug('Channel {} has ID {} on server {}.'.format(channel.name, channel.id, channel.server.id))
+      log.debug('Channel {} has ID {} on server {}.'.format(channel.name, channel.id, channel.guild.id))
       if not text_ok and channel.type != discord.ChannelType.voice:
         log.warning('Parsed channel {} of wrong type!'.format(channel_id))
         continue
@@ -258,7 +301,7 @@ def parse_channel(text, *, author = None, text_ok = True, voice_ok = True):
       if shared_with(author, channel):
         return channel
       if author is not None:
-        log.warning("User {} isn't on channel {} server {}!".format(author, channel.name, server.name))
+        log.warning("User {} isn't on channel {} server {}!".format(author, channel.name, channel.guild.name))
 
   log.warning("Can't find {} channel {}!".format(channel_type, text))
   return None
@@ -285,9 +328,9 @@ def parse_mention(text, *, author = None):
   if m is None:
     m = re.match(r'@?&(\d+)', text)
   if m is not None:
-    role_id = m.group(1)
+    role_id = snowflake(m.group(1))
     log.debug('Role ID {}.'.format(role_id))
-    for server in client.servers:
+    for server in client.guilds:
       member = server.get_member(author.id)
       if member is None:
         log.debug('Role {} is not known to {} on {}.'.format(role_id, author, server.name))
@@ -307,7 +350,7 @@ def parse_mention(text, *, author = None):
   if m is None:
     m = re.match(r'@?(\d+)', text)
   if m is not None:
-    member_id = m.group(1)
+    member_id = snowflake(m.group(1))
     log.debug('Member {}.'.format(member_id))
     log.info('Member {}'.format(member_id))
     return member_id
@@ -317,7 +360,7 @@ def parse_mention(text, *, author = None):
   if m is not None:
     name = m.group(2).lower()
     log.debug('Role {}.'.format(name))
-    for server in client.servers:
+    for server in client.guilds:
       member = server.get_member(author.id)
       if member is None:
         log.debug('Role {} is not known to {} on {}.'.format(name, author, server.name))
@@ -333,7 +376,7 @@ def parse_mention(text, *, author = None):
       log.debug('Member {}.'.format(name))
       member = get_member_by_name(name, author)
       if member is not None:
-        log.info('Parsed {} as {}#{} on {}'.format(name, member.name, member.discriminator, member.server.name))
+        log.info('Parsed {} as {} on {}'.format(name, canonicalise(member), member.guild.name))
         return member.id
     log.warning("Can't find role or member with name {}!".format(name))
     return None
@@ -348,14 +391,14 @@ def parse_mention(text, *, author = None):
     for channel in client.get_all_channels():
       if channel.name.lower() != name:
         continue
-      member = channel.server.get_member(author.id)
+      member = channel.guild.get_member(author.id)
       if member is None:
-        log.warning('Channel {} is not known to {} on {}.'.format(name, author, channel.server.name))
+        log.warning('Channel {} is not known to {} on {}.'.format(name, author, channel.guild.name))
         continue
       if channel.type == discord.ChannelType.voice:
         log.warning('Channel {} is a voice channel!'.format(channel.name))
         continue
-      log.info('Parsed channel {} as {} on {}.'.format(name, channel.name, channel.server.name))
+      log.info('Parsed channel {} as {} on {}.'.format(name, channel.name, channel.guild.name))
       return '#{}'.format(channel.id)
     log.warning("Can't find channel with name {}!".format(name))
     return None
@@ -369,9 +412,10 @@ async def parse_user(text, *, author = None):
   parsed = parse_mention(text, author = author)
   if parsed is None:
     return None
-  if not re.match(r'^\d+$', parsed):
+  user_id = str(parsed)
+  if not re.match(r'^\d+$', user_id):
     return None
-  user = await client.get_user_info(parsed)
+  user = await client.fetch_user(user_id)
   return user
 
 async def parse_role(text, *, author = None, server = None):
@@ -383,13 +427,13 @@ async def parse_role(text, *, author = None, server = None):
   if parsed in ['everyone', 'here']:
     if author is not None:
       if isinstance(author, discord.member.Member):
-        return author.server.default_role
+        return author.guild.default_role
   m = re.match(r'^&(\d+)$', parsed)
   if not m:
     return None
-  id = m.group(1)
+  id = snowflake(m.group(1))
   if server is None:
-    servers = client.servers
+    servers = client.guilds
   else:
     servers = [server]
   for server in servers:
@@ -420,7 +464,7 @@ def open_url(url):
 
 # Get my idle status.
 def status():
-  for server in client.servers:
+  for server in client.guilds:
     return server.me.status
   return None
 
@@ -433,7 +477,7 @@ async def set_avatar(url):
     log.debug('Failed to open URL {} for avatar'.format(url))
     return False
   try:
-    await client.edit_profile(avatar = fd.read())
+    await client.user.edit(avatar = fd.read())
     fd.close()
     return True
   except:
@@ -441,18 +485,27 @@ async def set_avatar(url):
     log.exception("set_avatar")
     return False
 
-async def set_idle(idle):
-  await client.change_presence(status = discord.Status.idle if idle else discord.Status.online)
-  for voice in list(client.voice_clients):
-    await voice.disconnect()
+async def set_idle(idle, activity = None):
+  await client.change_presence(
+    activity = activity if activity else discord.Activity(name = 'sleeping', start = datetime.datetime.utcnow(), details = 'zzz') if idle else None,
+    status = discord.Status.idle if idle else discord.Status.online
+  )
+  if idle:
+    for voice in list(client.voice_clients):
+      await voice.disconnect()
+
+async def set_activity(activity = None):
+  await client.change_presence(activity = activity)
 
-async def wake_up(force = False):
+async def wake_up(force = False, activity = None):
   set('last_spoke', time.time())
   if force or status() != discord.Status.online:
     log.info('Waking up...')
     await set_avatar(db.ONLINE_AVATAR)
-    await set_idle(False)
+    await set_idle(False, activity = activity)
     db.set_state(client, avatar = db.ONLINE_AVATAR, idle = False, last_spoke = get('last_spoke'))
+  elif activity:
+    await set_idle(False, activity = activity)
 
 async def zzz():
 # state = db.get_state(client)
@@ -506,22 +559,51 @@ def parse_attachment(attachment, filename = None):
 
   return parsed
 
-async def maybe_say(channel, text, *, attachment = None, embed = None, probability = 0.2, wake = True):
+def typing_context_manager(channel, typing = None):
+  class DummyContextManager(object):
+    def __init__(self):
+      return
+
+    def __enter__(self):
+      return self
+
+    def __exit__(self, exc_type, exc, tb):
+      return
+
+    async def __aenter__(self):
+      return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+      return
+
+  return DummyContextManager() if typing else channel.typing()
+
+# join may be passed by cat.maybe_purr() etc.
+async def maybe_say(channel, text, *, typing = None, attachment = None, embed = None, probability = 0.2, wake = True, join = None):
   if random.random() <= probability:
+    message = None
     try:
-      message = None
-      await client.send_typing(channel)
-      if attachment is not None:
-        parsed = parse_attachment(attachment)
-        if parsed is not None:
-          message = await client.send_file(channel, parsed['fd'], filename = parsed['filename'], content = text)
-          parsed['fd'].close()
-        else:
-          log.warning("Missing or invalid attachment!")
-      if message is None:
-        message = await client.send_message(channel, text, embed = embed)
+      ok = True
+      send = {}
+      async with typing_context_manager(channel, typing) as context_manager:
+        if embed:
+          send['embed'] = embed
+        if attachment is not None:
+          parsed = parse_attachment(attachment)
+          if parsed is not None:
+            send['file'] = discord.File(parsed['fd'], parsed['filename'])
+            parsed['fd'].close()
+          else:
+            log.warning("Missing or invalid attachment!")
+            ok = False
+      if ok:
+        message = await channel.send(text, **send)
     except Exception:
-      destination = 'private channel [{}]'.format(', '.join(user.name, user.discriminator) for user in channel.recipients if user.id != channel.me.id) if channel.is_private else 'channel {} on {}'.format(channel.name, channel.server.name)
+      if is_private(channel):
+        recipients = [user for user in channel.recipients if user.id != channel.me.id] if isinstance(channel, discord.GroupChannel) else [channel.recipient]
+        destination = 'private channel [{}]'.format(', '.join([canonicalise(user) for user in recipients]))
+      else:
+        destination = 'channel {} on {}'.format(channel.name, channel.guild.name)
       log.error('sending to {}: {}'.format(destination, traceback.format_exc().splitlines()[-1]))
     if wake:
       await wake_up()
@@ -530,8 +612,10 @@ async def maybe_say(channel, text, *, attachment = None, embed = None, probabili
     log.debug("Didn't bother to say {}".format(text))
     return None
 
-async def say(channel, text, *, attachment = None, embed = None, wake = None):
+async def say(channel, text, *, typing = None, attachment = None, embed = None, wake = None, join = None):
   args = { 'probability': 1.0 }
+  if typing is not None:
+    args['typing'] = typing
   if attachment is not None:
     args['attachment'] = attachment
   if embed is not None:
@@ -541,54 +625,137 @@ async def say(channel, text, *, attachment = None, embed = None, wake = None):
   message = await maybe_say(channel, text, **args)
   return message
 
-async def say_many(channel, lines, *, attachment = None, embed = None, maxlen = 2000):
-  while len(lines):
-    message_len = 0
-    n = 0
-    for i in range(0, len(lines)):
-      message_len += len(lines[i])
-      if i:
-        # Include newlines.
-        message_len += 1
-      if message_len > maxlen:
+async def say_many(channel, lines, *, typing = None, attachment = None, embed = None, maxlen = 2000):
+  async with typing_context_manager(channel, typing) as context_manager:
+    while len(lines):
+      message_len = 0
+      n = 0
+      for i in range(0, len(lines)):
+        message_len += len(lines[i])
+        if i:
+          # Include newlines.
+          message_len += 1
+        if message_len > maxlen:
+          break
+        n += 1
+      # We can't print too big a message.
+      subset = lines[:n]
+      message = '\n'.join(subset)
+      result = await say(channel, message, typing = context_manager, attachment = attachment, embed = embed)
+      if not result:
         break
-      n += 1
-    # We can't print too big a message.
-    subset = lines[:n]
-    message = '\n'.join(subset)
-    result = await say(channel, message, attachment = attachment, embed = embed)
-    if not result:
-      break
-    lines = lines[len(subset):]
-    attachment = None
-    embed = None
+      lines = lines[len(subset):]
+      attachment = None
+      embed = None
 
-def voice_channel_for_channel(channel):
+def voice_client_for_server(server):
   for voice in client.voice_clients:
-    if voice.server != channel.server:
-      continue
-    if not voice.is_connected:
+    if voice.guild != server:
       continue
+    return voice
+
+def voice_client_for_channel(channel):
+  return voice_client_for_server(channel.guild)
+
+def voice_client_for_user(user):
+  voice_channel = voice_channel_for_user(user)
+  if voice_channel is None:
+    return None
+  return voice_client_for_server(voice_channel.guild)
+
+def voice_channel_for_server(server):
+  voice = voice_client_for_server(server)
+  if voice:
     return voice.channel
 
 def voice_channel_for_user(user):
-  for server in client.servers:
+  for server in client.guilds:
     member = server.get_member(user.id)
     if not member:
       continue
-    voice_channel = member.voice_channel
+    if not member.voice:
+      continue
+    voice_channel = member.voice.channel
     if voice_channel is not None:
-      if voice_channel.server in client.servers:
+      if voice_channel.guild in client.guilds:
         return voice_channel
 
-async def create_player(voice, url):
+async def maybe_join_voice_channel(channel, join = False):
+  voice = voice_client_for_server(channel.guild)
+  if voice is None:
+    if not join:
+      return None
+    log.debug('Joining voice channel {} on {}'.format(channel.name, channel.guild.name))
+    voice = await channel.connect()
+    if not voice:
+      log.error("Can't join voice channel {} on {}!".format(channel.name, channel.guild.name))
+    voice.stop()
+    return voice
+  if voice.channel == channel:
+    if not voice.is_connected():
+      await voice.connect()
+    return voice
+  if voice.is_playing():
+    if not join:
+      log.debug('Not interrupting current sound to move to {} on {}'.format(channel.name, channel.guid.name))
+      return None
+  log.debug('Moving to voice channel {} on {}'.format(channel.name, channel.guild.name))
+  await voice.move_to(channel)
+  await asyncio.sleep(1)
+  if voice.channel != channel:
+    log.error("Can't move to voice channel {} on {}!".format(channel.name, channel.guild.name))
+    return None
+  voice.stop()
+  return voice
+
+async def join_voice_channel(channel):
+  voice = await maybe_join_voice_channel(channel, True)
+  return voice
+
+def tempfile_for_voice_channel(channel):
+  return os.path.join(tempfile.gettempdir(), str(channel.id))
+
+async def create_youtube_audio(voice, url, name = None):
+  import youtube_dl
+  filename = tempfile_for_voice_channel(voice.channel)
+  options = {
+    'format': 'bestaudio/best',
+    'outtmpl': filename,
+    'nooverwrites': False,
+    'noplaylist': True,
+    'nocheckcertificate': True,
+    'ignoreerrors': False,
+    'logtostderr': False,
+    'quiet': True,
+    'no_warnings': True,
+    'default_search': 'auto'
+  }
+
+  def after(error):
+    try:
+      os.unlink(filename)
+    except Exception as e:
+      log.error('Deleting Youtube temporary file {}: {}'.format(filename, str(e)))
+
+  try:
+    ytdl = youtube_dl.YoutubeDL(options)
+    info = ytdl.extract_info(url)
+    return [discord.FFmpegPCMAudio(filename), discord.Streaming(name = name if name else info.get('title', 'YouTube'), platform = 'YouTube', url = url), after]
+  except:
+    log.exception('Downloading {}'.format(url))
+    return [None, None, None]
+
+async def create_audio(voice, url, name = None):
   if re.match(r'https?://(?:www\.)?youtu(?:\.be|be\.com)/', url):
-    player = await voice.create_ytdl_player(url)
+    audio, activity, after = await create_youtube_audio(voice, url, name = name)
   else:
-    player = voice.create_ffmpeg_player(url)
-  return player
+    audio = discord.FFmpegPCMAudio(url)
+    activity = discord.Game(name = name if name else url)
+    after = None
+  return [audio, activity, after]
 
-async def maybe_play_sound(channel, filename, *, probability = 0.05, join = True):
+# wake may be passed by cat.maybe_purr() etc.
+async def maybe_play_sound(channel, filename, *, name = None, probability = 0.05, join = True, wake = None):
   if not channel or not filename:
     if join:
       log.warning('Missing channel and/or filename for play_sound()')
@@ -602,47 +769,47 @@ async def maybe_play_sound(channel, filename, *, probability = 0.05, join = True
     await asyncio.sleep(1)
 
   set('playing', True)
-  voice = client.voice_client_in(channel.server)
-  if voice is None or voice.channel != channel:
+  voice = await maybe_join_voice_channel(channel, join)
+  if not voice:
     if not join:
       log.info('Not joining voice channel {} just to play {}'.format(channel, filename))
-      return False
-    log.debug('Moving to voice channel {}'.format(channel))
-    if voice is not None:
-      await voice.move_to(channel)
-      await asyncio.sleep(1)
-    else:
-      await client.join_voice_channel(channel)
-    voice = client.voice_client_in(channel.server)
-    if voice.channel != channel:
-      log.error("Can't move to voice channel {}".format(channel))
-      set('playing', False)
-      return False
+    set('playing', False)
+    return False
   try:
     log.info('Playing {} in channel {}'.format(filename, channel))
-    player = await create_player(voice, filename)
-    player.start()
-    while not player.is_done():
+    audio, activity, after = await create_audio(voice, filename, name = name)
+    if audio is None:
+      log.error("Can't initialise audio stream from {}!".format(filename))
+      set('playing', False)
+      await set_activity(None)
+      return False
+    await wake_up(activity = activity)
+    voice.play(audio, after = after)
+    while voice.is_playing():
       if get('interrupt_playing'):
         log.info('Interrupted!')
-        player.stop()
+        voice.stop()
         break
       await asyncio.sleep(1)
     set('playing', False)
     set('interrupt_playing', False)
+    await set_activity(None)
     log.info('Finished playing {} in channel {}'.format(filename, channel))
     return True
   except:
-    log.exception("Error creating ffpmeg player for {}".format(filename))
+    log.exception("Creating audio source for {}".format(filename))
     set('playing', False)
     set('interrupt_playing', False)
+    await set_activity(None)
     return False
 
-async def play_sound(channel, filename, *, join = True):
-  result = await maybe_play_sound(channel, filename, probability = 1.0, join = join)
+async def play_sound(channel, filename, *, name = None, join = True, wake = None):
+  result = await maybe_play_sound(channel, filename, name = name, probability = 1.0, join = join)
   return result
 
-async def tts(channel, text, args = {}):
+async def tts(channel, text, args = {}, name = None):
+  if not name:
+    name = 'text-to-speech'
   try:
     fd, wav = tempfile.mkstemp('.tts')
     os.close(fd)
@@ -668,18 +835,18 @@ async def tts(channel, text, args = {}):
     os.unlink(wav)
     return False
 
-  await play_sound(channel, wav)
+  await play_sound(channel, wav, name = name)
   os.unlink(wav)
   return True
 
 def lower_role_than(member, *, strict = False):
-  if member.server.me is None:
+  if member.guild.me is None:
     log.error("Can't call lower_role_than() a member on a server we aren't on!")
     return False
   if strict:
-    return member.top_role > member.server.me.top_role
+    return member.top_role > member.guild.me.top_role
   else:
-    return member.top_role >= member.server.me.top_role
+    return member.top_role >= member.guild.me.top_role
 
 def lower_role_on_server_than(server, id, *, strict = False):
   member = server.get_member(id)
@@ -698,7 +865,7 @@ def equal_permissions(x, y, *, strict = True):
 
 def permission(permission_name, allow = True):
   overwrite = discord.PermissionOverwrite()
-  if permission_name not in overwrite.VALID_NAMES:
+  if permission_name not in getattr(overwrite, 'VALID_NAMES'):
     log.error("Invalid permission name {}!".format(permission_name))
     return None
   args = { permission_name: allow }
@@ -711,11 +878,11 @@ async def overwrite_permissions(channel, whom, overwrite, *, strict = True):
     log.debug('Permissions for {} in {} are already correct.'.format(whom.name, channel.name))
     return
   log.info('Setting permissions for {} in {} to {}'.format(whom.name, channel.name, permissions.__dict__))
-  await client.edit_channel_permissions(channel, whom, overwrite)
+  await channel.set_permissions(whom, overwrite)
 
 async def set_role(member, role, minimum = 1):
   if role is None:
-    log.warning("Can't find {} role to assign to {}".format(role_name, member.name))
+    log.warning("Can't find role to assign to {}".format(member.name))
     return
 
   if minimum is not None and len(member.roles) > minimum:
@@ -726,7 +893,7 @@ async def set_role(member, role, minimum = 1):
     return
   log.info('Adding {} role to {}'.format(role.name, member.name))
   try:
-    await client.add_roles(member, role)
+    await member.add_roles(role)
   except discord.errors.Forbidden:
     log.warning('Forbidden to add {} role to {}'.format(role.name, member.name))
 
diff --git a/cat.py b/cat.py
index f33b5f6..f44a9da 100644 (file)
--- a/cat.py
+++ b/cat.py
@@ -2,10 +2,12 @@ import asyncio
 import bot
 
 async def maybe_meow(destination, voice = None, **args):
-  result = False
+  result = None
   if destination is not None:
     result = await bot.maybe_say(destination, 'meow!', **args)
-  if result and voice is not None:
+  if result is not False and voice is not None:
+    play_args = { k: v for k, v in args.items() }
+    play_args['name'] = 'meowing sound'
     await bot.play_sound(voice, 'meow.wav', **args)
 
 async def meow(destination, voice = None, **extra):
@@ -14,12 +16,20 @@ async def meow(destination, voice = None, **extra):
   args['probability'] = 1.0
   await maybe_meow(destination, voice, **args)
 
-async def purr(destination, voice = None, **args):
-  result = False
+async def maybe_purr(destination, voice = None, **args):
+  result = None
   if destination is not None:
-    result = await bot.say(destination, 'purr')
-  if result and voice is not None:
-    await bot.play_sound(voice, 'purr.wav', **args)
+    result = await bot.maybe_say(destination, 'purr', **args)
+  if result is not False and voice is not None:
+    play_args = { k: v for k, v in args.items() }
+    play_args['name'] = 'purring sound'
+    await bot.play_sound(voice, 'purr.wav', **play_args)
+
+async def purr(destination, voice = None, **extra):
+  args = {}
+  args.update(**extra)
+  args['probability'] = 1.0
+  await maybe_purr(destination, voice, **args)
 
 async def hiss(destination, **args):
   await bot.say(destination, 'hiss!', **args)
diff --git a/db.py b/db.py
index 546c24f..08f3c8e 100644 (file)
--- a/db.py
+++ b/db.py
@@ -1,4 +1,4 @@
-import discord_unofficial as discord
+import discord
 import logging
 import sqlite3
 import re
@@ -6,6 +6,8 @@ import time
 import uuid
 from types import GeneratorType
 
+import bot
+
 log = logging.getLogger('db')
 
 class DBConnection(object):
@@ -93,26 +95,33 @@ class DBConnection(object):
     self.commit()
     self.close_db()
 
-  def get_all_from_table(self, client, table, servers = []):
+  def snowflake_row(self, row):
+    ret = dict(row)
+    for k, v in ret.items():
+      if k.endswith('_id'):
+        ret[k] = bot.snowflake(v)
+    return ret
+
+  def get_all_from_table(self, client, table, servers = [], snowflake = False):
     params = [client.user.id]
     sql = 'select * from {} where bot_id=?'.format(table)
     if len(servers):
       sql += ' and server_id in ({})'.format(', '.join(['?'] * len(servers)))
       for server in servers:
-        if type(server) == discord.server.Server:
+        if type(server) == discord.guild.Guild:
           params.append(server.id)
         else:
           params.append(server)
     cursor = self.query(sql, params)
     for row in cursor.fetchall():
-      yield dict(row)
+      yield self.snowflake_row(row) if snowflake else dict(row)
     self.close_db()
 
-  def get_from_table(self, client, table, id):
+  def get_from_table(self, client, table, id, snowflake = False):
     cursor = self.query('select * from {} where bot_id=? and id=?'.format(table), [client.user.id, id])
     row = cursor.fetchone()
     if row:
-      ret = dict(row)
+      ret = self.snowflake_row(row) if snowflake else dict(row)
     else:
       ret = None
     self.close_db()
@@ -171,15 +180,3 @@ class DBConnection(object):
       ret = False
     self.close_db()
     return ret
-
-  def get_message_servers(self, client, message, allow_private = False):
-    servers = []
-    if message.channel.is_private:
-      if allow_private:
-        servers.append('private')
-      for server in client.servers:
-        if message.author in server.members:
-          servers.append(server)
-    else:
-      servers = [message.channel.server]
-    return servers
similarity index 93%
rename from plugins.py
rename to plugins/__init__.py
index bf64d75..4cd28c1 100644 (file)
@@ -33,7 +33,7 @@ class Plugins(object):
 
   def refresh(self):
     seen = []
-    for py in glob.glob('plugin/*/__init__.py') + glob.glob('plugin/*/*.py'):
+    for py in glob.glob('plugins/*/__init__.py') + glob.glob('plugins/*/*.py'):
       path = py.split('.py')[0]
       plugin = os.path.basename(path)
       name = os.path.basename(os.path.dirname(py))
@@ -72,7 +72,7 @@ class Plugins(object):
   def valid_commands(self):
     commands = []
     for name in self.plugins():
-      plugin_commands = self.call_method(name, 'valid_commands')
+      plugin_commands = self.call_method(name, 'valid_commands') if self.has_method(name, 'valid_commands') else None
       if plugin_commands is not None:
         commands += plugin_commands
     return sorted(list(set(commands)))
@@ -151,10 +151,14 @@ class Plugins(object):
   # Plugin can return True to force skipping other handlers.
   async def handle_command(self, message, command, raw):
     for name in self.method_cache('handle_command'):
+      commands = []
       if self.has_method(name, 'valid_commands'):
-        if command not in self.call_method(name, 'valid_commands'):
-          log.debug("Plugin {} doesn't handle {} command.".format(name, command))
-          continue
+        commands += self.call_method(name, 'valid_commands')
+      if self.has_method(name, 'secret_commands'):
+        commands += self.call_method(name, 'secret_commands')
+      if command not in commands:
+        log.debug("Plugin {} doesn't handle {} command.".format(name, command))
+        continue
       result = await self.call_coroutine(name, 'handle_command', message, command, raw)
       if result is PluginCommand.ignored:
         log.debug('Plugin {} ignored command {}.'.format(name, command))
similarity index 98%
rename from plugin/admin/admin.py
rename to plugins/admin/__init__.py
index e980f84..fbd452f 100644 (file)
@@ -248,6 +248,7 @@ class Admin(object):
       allow = False
 
     log.info('{} server={} role={} permission={} channel={}'.format(action, where, who, permission, channel))
+    permissions = None
     overwrite = bot.permission(permission, allow)
     if overwrite is None:
       await bot.say(message.channel, '{}?'.format(permission))
@@ -277,7 +278,7 @@ class Admin(object):
       permissions = member.server_permissions
     elif parsed[0] == '&':
       # Role.
-      role_id = parsed[1:]
+      role_id = bot.snowflake(parsed[1:])
       for role in server.roles:
         if role.id == role_id:
           target = role
@@ -311,7 +312,7 @@ class Admin(object):
     permissions.update(**args)
 
     if parsed[0] == '&':
-      await client.edit_role(server, target, permissions = permissions)
+      await target.edit(permissions = permissions)
     else:
       await bot.say(message.channel, 'Need channel to update member permissions!')
       return
similarity index 68%
rename from plugin/affection/affection.py
rename to plugins/affection/__init__.py
index a9fedd9..7f626eb 100644 (file)
@@ -1,7 +1,9 @@
 import asyncio
+import random
 
 from plugins import PluginCommand
 import bot
+import cat
 
 class Affection(object):
   def __init__(self):
@@ -16,26 +18,23 @@ class Affection(object):
   def secret_commands(self):
     return self.eat + self.drink + self.drugs + self.fuss
 
-  @asyncio.coroutine
-  def handle_command(self, message, command, raw):
+  async def handle_command(self, message, command, raw):
     if command not in self.secret_commands():
       return PluginCommand.ignored
-    result = yield from self.handle_affection(message, command)
+    result = await self.handle_affection(message, command)
     if result:
       return PluginCommand.exclusive
     else:
       return PluginCommand.handled
 
-  @asyncio.coroutine
-  def handle_help(self, message, command, *args):
+  async def handle_help(self, message, command, *args):
     if len(args):
       if command in self.secret_commands():
-        yield from self.help_affection(message, *args)
+        await self.help_affection(message, *args)
         return PluginCommand.handled
     return PluginCommand.ignored
 
-  @asyncio.coroutine
-  def handle_affection(self, message, command):
+  async def handle_affection(self, message, command):
     if command in self.eat:
       responses = ['om nom nom', '=^-^=', 'meow!', 'lick', ':cat:', ':cat2:', ':fork_and_knife:', ':smiley_cat:']
     elif command in self.drink:
@@ -44,17 +43,15 @@ class Affection(object):
       responses = ['>(@.@)<']
     else:
       responses = ['nuzzle', '=^-^=', 'meow!', 'mrrp', ':cat:', ':cat2:', ':paw_prints:', ':smiley_cat:']
-    for response in responses:
-      said = yield from bot.maybe_say(message.channel, response)
-      if said:
-        yield from bot.maybe_play_sound(bot.voice_channel_for_user(message.author), 'purr.wav', join = False)
-        return True
+    said = await bot.maybe_say(message.channel, random.choice(responses))
+    if said:
+      await cat.maybe_purr(None, voice = bot.voice_channel_for_user(message.author), join = False)
+      return True
     return False
 
-  @asyncio.coroutine
-  def help_affection(self, message, args):
+  async def help_affection(self, message, args):
     lines = [
       "You can give me a treat if you like.  I may (or may not) show gratitude.",
       'Probably not.'
     ]
-    yield from bot.say_lines(message.channel, many)
+    await bot.say_many(message.channel, lines)
similarity index 97%
rename from plugin/announcements/announcements.py
rename to plugins/announcements/__init__.py
index fa7f47d..0ffb306 100644 (file)
@@ -14,7 +14,11 @@ import cat
 class Announcement(object):
   def __init__(self, row):
     for k, v in dict(row).items():
-      setattr(self, k, v)
+      canon = v
+      if k.endswith('_id'):
+        if k != 'channel_id' or v != 'private':
+          canon = bot.snowflake(v)
+      setattr(self, k, canon)
     if not hasattr(self, 'message_limit'):
       self.message_limit = None
     self.is_private = hasattr(self, 'channel_id') and self.channel_id == 'private'
@@ -143,7 +147,7 @@ class Announcements(DBConnection):
           log.debug('Anyone can show own announcements.')
           return announcement
         if not announcement.is_private:
-          server = client.get_server(announcement.server_id)
+          server = bot.get_server(announcement.server_id)
           if server is not None:
             member = server.get_member(author.id)
             if member in server.members:
@@ -156,7 +160,7 @@ class Announcements(DBConnection):
           log.debug('Anyone can manage own announcements.')
           return announcement
         if not announcement.is_private:
-          server = client.get_server(announcement.server_id)
+          server = bot.get_server(announcement.server_id)
           announcer = server.get_member(announcement.member_id)
           if announcer is None:
             announcer = server.me
@@ -165,7 +169,7 @@ class Announcements(DBConnection):
             log.debug('Member {} with role {} on server {} can edit announcements from lower role.'.format(member.name, member.top_role.name, server.name))
             return announcement
           elif command == 'asap' or member.top_role == announcer.top_role:
-            if member.top_role >= member.server.me.top_role:
+            if member.top_role >= member.guild.me.top_role:
               log.debug('Member {} with role {} on server {} can edit announcements.'.format(member.name, member.top_role.name, server.name))
               return announcement
 
@@ -173,7 +177,7 @@ class Announcements(DBConnection):
       if announcement.is_private:
         log.debug('Anyone can create a private announcement.')
         return announcement
-      server = client.get_server(announcement.server_id)
+      server = bot.get_server(announcement.server_id)
       member = server.get_member(announcement.member_id)
       if bot.lower_role_than(member, strict = True):
         log.debug('Member {} with role {} on server {} can create announcements.'.format(member.name, member.top_role.name, server.name))
@@ -300,14 +304,14 @@ class Announcements(DBConnection):
       return
 
     results = []
-    for announcement in self.get_all_announcements(client, self.get_message_servers(client, message, True)):
+    for announcement in self.get_all_announcements(client, bot.get_message_servers(message, True)):
       if announcement.is_private and not announcement.owned_by(message.author):
         continue
       text = 'announcement **{}**'.format(announcement.id)
       if announcement.is_private:
         text += ' in private'
       else:
-        server = client.get_server(announcement.server_id)
+        server = bot.get_server(announcement.server_id)
         member = server.get_member(announcement.member_id)
         text += ' by {}'.format(member.name)
       if announcement.channel_id:
@@ -339,13 +343,13 @@ class Announcements(DBConnection):
     lines = []
 
     if announcement.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(announcement.member_id)
         except:
           log.exception("announce")
     else:
-      server = client.get_server(announcement.server_id)
+      server = bot.get_server(announcement.server_id)
       member = server.get_member(announcement.member_id)
     text = '**{}announcement {} by {}'.format('private ' if announcement.is_private else '', id, member.name if member is not None else announcement.member_id)
     now = int(time.time())
@@ -383,7 +387,7 @@ class Announcements(DBConnection):
       text += ' every {}'.format(bot.unparse_seconds(int(announcement.interval)))
     mention = announcement.mention
     if mention:
-      if message.channel.is_private and mention[0] == '&':
+      if bot.is_private(message.channel) and mention[0] == '&':
         # Can't mention a role in a private channel.
         text += ' tell @{}'.format(mention)
       else:
@@ -524,7 +528,7 @@ class Announcements(DBConnection):
         elif param == 'here':
           if arg != 'in':
             break
-          if message.channel.is_private:
+          if bot.is_private(message.channel):
             parsed[k] = 'private'
             ok = True
           else:
@@ -615,14 +619,14 @@ class Announcements(DBConnection):
         if not channel:
           await bot.say(message.channel, 'Invalid channel!')
           return None
-        server = channel.server
+        server = channel.guild
         parsed['server_id'] = server.id
     elif 'voice_id' in parsed:
       voice = bot.parse_channel(parsed['voice_id'], author = message.author, text_ok = False)
       if not voice:
         await bot.say(message.channel, 'Invalid voice channel!')
         return None
-      server = voice.server
+      server = voice.guild
       parsed['server_id'] = server.id
 
     if 'start_date' in parsed and 'end_date' in parsed:
@@ -808,7 +812,7 @@ class Announcements(DBConnection):
 
     channel = None
     if announcement.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(announcement.member_id)
           channel = await client.start_private_message(member)
@@ -845,7 +849,7 @@ class Announcements(DBConnection):
         cutoff = datetime.datetime.utcnow() - datetime.timedelta(0, interval)
         log.debug('Getting logs from {}.'.format(cutoff))
         logs = []
-        async for message in client.logs_from(channel, after = cutoff):
+        async for message in channel.history(after = cutoff):
           logs.append(message)
         # Don't spam the same message in a quiet channel even if it hasn't
         # been posted since the cutoff.
@@ -853,7 +857,7 @@ class Announcements(DBConnection):
         if len(logs) < message_limit:
           log.debug('Fewer than {} messages ({}).'.format(message_limit, len(logs)))
           logs = []
-          async for message in client.logs_from(channel, limit = message_limit):
+          async for message in channel.history(limit = message_limit):
             logs.append(message)
         for message in logs:
           raw = re.sub(r'^(<?@\S+\s)+', '', message.content).strip()
similarity index 88%
rename from plugin/bling/bling.py
rename to plugins/bling/__init__.py
index 82de8fd..55abe16 100644 (file)
@@ -14,7 +14,8 @@ import cat
 
 class Bling(object):
   def __init__(self):
-    self.path = os.path.dirname(__file__)[(len(os.getcwd()) + 1):]
+    self.path = os.path.split(__file__)[0]
+    self.prefix = os.path.join(*self.path.split(os.path.sep)[-3:])
     self.fgname = 'fg.png'
     self.bgname = 'bg.png'
     self.maskname = 'mask.png'
@@ -56,8 +57,8 @@ class Bling(object):
       '```bling --profiles```',
       'List the `PROFILE`s that can be used with `--profile` to generate avatars.'
       '',
-      'If no `PROFILE` is specified with `--profile` I will use the `default` profile.  All `PROFILE`s, including `default`, are installed by my administrator under a subdirectory of `{0}`.  The background image should be installed as `{1}` and the foreground image should be installed as `{2}`, for example `{0}{3}default{3}{1} and {0}{3}default{3}{2}.'.format(self.path, self.bgname, self.fgname, os.sep),
-      'Your image will be overlaid *above* {} and *beneath* {}.'.format(self.bgname, self.fgname)
+      'If no `PROFILE` is specified with `--profile` I will use the `default` profile.  All `PROFILE`s, including `default`, are installed by my administrator under a subdirectory of `{0}`.  The background image should be installed as `{1}` and the foreground image should be installed as `{2}`, for example `{0}{3}default{3}{1}` and `{0}{3}default{3}{2}`.'.format(self.prefix, self.bgname, self.fgname, os.sep),
+      'Your image will be overlaid *above* `{}` and *beneath* `{}`.'.format(self.bgname, self.fgname)
     ]
     await bot.say_many(message.channel, lines)
 
@@ -99,13 +100,13 @@ class Bling(object):
   def parse_message(self, message, name):
     parsed = {
       'filename': message.author.name,
-      'mentions': [message.author.mention] if not message.channel.is_private else None,
+      'mentions': [message.author.mention] if not bot.is_private(message.channel) else None,
       'url': None
     }
 
     if name is not None:
       id = bot.parse_mention(name, author = message.author)
-      m = re.match(r'^\d+', id) if id is not None else None
+      m = re.match(r'^\d+', str(id)) if id is not None else None
       if m is not None:
         other = None
         for member in client.get_all_members():
@@ -143,15 +144,14 @@ class Bling(object):
       await cat.yelp(message.channel)
       return
 
-    await client.send_typing(message.channel)
+    with message.channel.typing() as typing:
+      data = self.create_avatar(parsed['url'], os.sep.join([self.path, profile, self.bgname]), os.sep.join([self.path, profile, self.fgname]), os.sep.join([self.path, profile, self.maskname]))
 
-    data = self.create_avatar(parsed['url'], os.sep.join([self.path, profile, self.bgname]), os.sep.join([self.path, profile, self.fgname]), os.sep.join([self.path, profile, self.maskname]))
-    if data is None:
-      await cat.yelp(message.channel)
-      return
-
-    text = ' '.join(parsed['mentions']) if parsed['mentions'] is not None else 'purr'
-    await bot.say(message.channel, text, attachment = bot.parse_attachment(data, parsed['filename'] + '.png'))
+      if data:
+        text = ' '.join(parsed['mentions']) if parsed['mentions'] is not None else 'purr'
+        await bot.say(message.channel, text, attachment = bot.parse_attachment(data, parsed['filename'] + '.png'), typing = typing)
+      else:
+        await cat.yelp(message.channel, typing = typing)
 
   def create_avatar(self, url, bgfile, fgfile, maskfile = None):
     # Get bling images.
similarity index 100%
rename from plugin/bling/default
rename to plugins/bling/default
similarity index 74%
rename from plugin/developer/developer.py
rename to plugins/developer/__init__.py
index f5ac7ca..fab181c 100644 (file)
@@ -1,12 +1,12 @@
 import asyncio
-import discord_unofficial as discord
+import discord
 import shlex
 import re
 import sys
 
 from plugins import PluginCommand
 from enum import Enum
-from throwingargparse import ThrowingArgumentParser
+from throwingargparse import ThrowingArgumentParser, ArgumentParserError
 import bot
 import cat
 
@@ -23,11 +23,11 @@ class Identify(Enum):
       return 'User'
     elif type(instance) == discord.role.Role:
       return 'Role'
-    elif type(instance) == discord.channel.Channel:
-      if instance.type == discord.ChannelType.voice:
-        return 'Voice Channel ({}bps)'.format(instance.bitrate)
+    elif isinstance(instance, discord.TextChannel):
       return 'Channel'
-    elif type(instance) == discord.server.Server:
+    elif isinstance(instance, discord.VoiceChannel):
+        return 'Voice Channel ({}bps)'.format(instance.bitrate)
+    elif type(instance) == discord.guild.Guild:
       return 'Server'
 
 class Developer(object):
@@ -37,27 +37,23 @@ class Developer(object):
   def valid_commands(self):
     return ['digest', 'id']
 
-  @asyncio.coroutine
-  def handle_command(self, message, command, raw):
+  async def handle_command(self, message, command, raw):
     if command not in self.valid_commands():
       return PluginCommand.ignored
-    yield from self.run_command(message, shlex.split(raw))
+    await self.run_command(message, shlex.split(raw))
     return PluginCommand.exclusive
 
-  @asyncio.coroutine
-  def handle_help(self, message, command, *args):
-    yield from self.help_command(message, command, *args)
+  async def handle_help(self, message, command, *args):
+    await self.help_command(message, command, *args)
 
-  @asyncio.coroutine
-  def run_command(self, message, args):
+  async def run_command(self, message, args):
     command = args[0]
     if command == 'digest':
-      yield from self.handle_digest(message, args[1:])
+      await self.handle_digest(message, args[1:])
     elif command == 'id':
-      yield from self.handle_id(message, args[1:])
+      await self.handle_id(message, args[1:])
 
-  @asyncio.coroutine
-  def help_command(self, message, command):
+  async def help_command(self, message, command):
     if command == 'digest':
       lines = [
         'Post the digest of a message.',
@@ -92,50 +88,47 @@ class Developer(object):
       ]
     else:
       lines = ['*shrugs*']
-    yield from bot.say_many(message.channel, lines)
+    await bot.say_many(message.channel, lines)
 
-  @asyncio.coroutine
-  def get_message(self, message, channel_id, message_id):
+  async def get_message(self, message, channel_id, message_id):
     channel = bot.parse_channel(channel_id, author = message.author, voice_ok = False)
     if not channel:
-      yield from bot.say(message.channel, 'Channel?')
+      await bot.say(message.channel, 'Channel?')
       return None
 
-    member = channel.server.get_member(message.author.id)
+    member = channel.guild.get_member(message.author.id)
     if member is None:
-      log.info('User {} is not a member of channel {} server {}.'.format(message.author, channel_id, channel.server))
+      log.info('User {} is not a member of channel {} server {}.'.format(message.author, channel_id, channel.guild))
     permissions = channel.permissions_for(member)
     if permissions is None:
       log.info('User {} has no permissions in channel {}.'.format(message.author, channel_id))
-      yield from bot.say(message.channel, 'Channel?')
+      await bot.say(message.channel, 'Channel?')
       return None
     if not permissions.read_message_history:
       log.info('User {} has no read message history permission in channel {}.'.format(message.author, channel_id))
-      yield from bot.say(message.channel, 'Message?')
+      await bot.say(message.channel, 'Message?')
       return None
 
     m = re.match(r'^\d+$', message_id)
     if m is not None:
       try:
-        result = yield from client.get_message(channel, message_id)
+        result = await channel.fetch_message(message_id)
         if result is not None:
           return result
       except discord.errors.Forbidden:
         log.warning('Forbidden to retrieve message {} in {}.'.format(message_id, channel_id))
-      yield from bot.say(message.channel, 'Message?')
+      await bot.say(message.channel, 'Message?')
       return None
 
-  @asyncio.coroutine
-  def handle_digest(self, message, args):
+  async def handle_digest(self, message, args):
     if len(args) > 1:
-      result = yield from self.get_message(message, *args)
+      result = await self.get_message(message, *args)
       if result is not None:
-        yield from bot.say(message.channel, bot.digest(result.content))
+        await bot.say(message.channel, bot.digest(result.content))
         return
-    yield from cat.yelp(message.channel)
+    await cat.yelp(message.channel)
 
-  @asyncio.coroutine
-  def handle_id(self, message, args):
+  async def handle_id(self, message, args):
     do = Identify.member.value | Identify.role.value | Identify.channel.value | Identify.server.value
     target = None
     channel_id = None
@@ -153,7 +146,7 @@ class Developer(object):
     try:
       parsed = ap.parse_args(args)
     except ArgumentParserError:
-      yield from cat.yelp(message.channel)
+      await cat.yelp(message.channel)
       return
 
     if parsed.target:
@@ -172,7 +165,7 @@ class Developer(object):
           channel_id = str(id)
           do = Identify.channel.value
       elif re.match(r'^\d+$', parsed.target):
-        server_id = str(target)
+        server_id = bot.snowflake(target)
       else:
         target = parsed.target
     elif parsed.channel or parsed.member or parsed.role or parsed.server:
@@ -188,7 +181,7 @@ class Developer(object):
 
     results = []
     if do & Identify.member.value:
-      for server in client.servers:
+      for server in client.guilds:
         if member_id:
           member = server.get_member(member_id)
           if member is not None:
@@ -204,13 +197,13 @@ class Developer(object):
             results.append(member)
     if do & Identify.role.value:
       if role_id:
-        for server in client.servers:
+        for server in client.guilds:
           for role in server.roles:
             if role.id == role_id:
               results.append(role)
               break
       else:
-        for server in client.servers:
+        for server in client.guilds:
           for role in server.roles:
             if target and role.name.lower() != target.lower():
               continue
@@ -227,13 +220,13 @@ class Developer(object):
           results.append(channel)
     if do & Identify.server.value:
       if server_id:
-        for server in client.servers:
+        for server in client.guilds:
           if server.id == server_id:
             results.append(server)
       else:
-        for server in client.servers:
+        for server in client.guilds:
           if target and server.name.lower() != target.lower():
             continue
           results.append(server)
 
-    yield from bot.say_many(message.channel, ['{} {} {}{}'.format(Identify.string(result), result.id, result.name, ' {}'.format(result.joined_at) if type(result) == discord.member.Member else '') for result in set(results) if bot.shared_with(message.author, result, allow_admin_override = bool(parsed.admin))])
+    await bot.say_many(message.channel, ['{} {} {}{}'.format(Identify.string(result), result.id, result.name, ' {}'.format(result.joined_at) if type(result) == discord.member.Member else '') for result in set(results) if bot.shared_with(message.author, result, allow_admin_override = bool(parsed.admin))])
similarity index 92%
rename from plugin/edts/__init__.py
rename to plugins/edts/__init__.py
index 173d293..7455ead 100644 (file)
@@ -35,7 +35,7 @@ class Edts(object):
     await self.wait_for_prompt()
     if self.edi.before:
       log.debug(self.edi.before)
-      await bot.say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not message.channel.is_private else '', self.edi.before))
+      await bot.say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not bot.is_private(message.channel) else '', self.edi.before))
     else:
       await bot.say(message.channel, 'Sorry!')
     return PluginCommand.exclusive
similarity index 97%
rename from plugin/faction/faction.py
rename to plugins/faction/__init__.py
index 98f3a15..97f848f 100644 (file)
@@ -23,18 +23,18 @@ class TrackedFaction(object):
     for k, v in dict(row).items():
       setattr(self, k, v)
     if hasattr(self, 'newsflash_channel_id'):
-      self.is_private = hasattr(self, 'newsflash_channel_id') and self.newsflash_channel_id == 'private'
+      self.is_private = hasattr(self, 'newsflash_channel_id') and getattr(self, 'newsflash_channel_id') == 'private'
     else:
       self.newsflash_channel_id = None
-      self.is_private = hasattr(self, 'channel_id') and self.channel_id == 'private'
+      self.is_private = hasattr(self, 'channel_id') and getattr(self, 'channel_id') == 'private'
 
   def owned_by(self, owner):
     try:
       # owner is a User object.
-      return self.member_id == owner.id
+      return getattr(self, 'member_id') == owner.id
     except AttributeError:
       # owner is a string.
-      return self.member_id == owner
+      return getattr(self, 'member_id') == owner
 
 class TrackedSystem(TrackedFaction):
   def __init__(self, row):
@@ -74,7 +74,7 @@ class Faction(DBConnection):
     self.close_db()
 
   def get_all_tracked_factions(self, servers = []):
-    for row in self.get_all_from_table(client, 'factions', servers):
+    for row in self.get_all_from_table(client, 'factions', servers, snowflake = True):
       yield TrackedFaction(row)
 
   def get_tracked_faction(self, id):
@@ -98,11 +98,11 @@ class Faction(DBConnection):
     return self.delete_from_table(client, 'factions', id)
 
   def get_all_tracked_systems(self, servers = []):
-    for row in self.get_all_from_table(client, 'systems', servers):
+    for row in self.get_all_from_table(client, 'systems', servers, snowflake = True):
       yield TrackedSystem(row)
 
   def get_tracked_system(self, id):
-    row = self.get_from_table(client, 'systems', id)
+    row = self.get_from_table(client, 'systems', id, snowflake = True)
     return TrackedSystem(row) if row else None
 
   def get_tracked_systems(self):
@@ -417,7 +417,7 @@ class Faction(DBConnection):
       return
 
     results = []
-    servers = self.get_message_servers(client, message, True)
+    servers = bot.get_message_servers(message, True)
     for tracked in self.get_all_tracked_factions(servers) if what == 'faction' else self.get_all_tracked_systems(servers):
       if tracked.is_private and not tracked.owned_by(message.author):
         continue
@@ -425,14 +425,18 @@ class Faction(DBConnection):
       if tracked.is_private:
         text += ' in private'
       else:
-        server = client.get_server(tracked.server_id)
+        server = bot.get_server(tracked.server_id)
         member = server.get_member(tracked.member_id)
-        text += ' by {}'.format(member.name)
-        text += ' in <#{}>'.format(tracked.channel_id)
+        if member is not None:
+          text += ' by {}'.format(member.name)
+        if tracked.channel_id:
+          text += ' in <#{}>'.format(tracked.channel_id)
         if tracked.newsflash_channel_id:
           text += ' news <#{}>'.format(tracked.newsflash_channel_id)
       if what == 'faction':
-        text += ' last state {} at {}'.format(eddb.state_name(tracked.state_id), bot.iso8601(tracked.updated))
+        text += ' last state {}'.format(eddb.state_name(tracked.state_id))
+        if tracked.updated:
+          text += ' at {}'.format(bot.iso8601(tracked.updated))
       results.append(text)
 
     if len(results):
@@ -457,13 +461,13 @@ class Faction(DBConnection):
     lines = []
 
     if tracked.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(tracked.member_id)
         except:
           log.exception(what)
     else:
-      server = client.get_server(tracked.server_id)
+      server = bot.get_server(tracked.server_id)
       member = server.get_member(tracked.member_id)
     text = '**{} tracking {} {} by {}'.format(id, what, tracked.name, member.name if member is not None else tracked.member_id)
     now = self.now()
@@ -583,15 +587,15 @@ class Faction(DBConnection):
 
     channel = None
     if tracked.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(tracked.member_id)
-          channel = await client.start_private_message(member)
+          channel = await member.create_dm()
           break
         except:
           log.exception('report_tracked_faction')
     else:
-      channel = client.get_channel(tracked.channel_id)
+      channel = bot.get_channel(tracked.channel_id)
 
     if channel is not None:
       result = await self.get_faction(None, tracked.name)
@@ -617,15 +621,15 @@ class Faction(DBConnection):
 
     channel = None
     if tracked.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(tracked.member_id)
-          channel = await client.start_private_message(member)
+          channel = await member.create_dm()
           break
         except:
           log.exception('report_tracked_system')
     else:
-      channel = client.get_channel(tracked.channel_id)
+      channel = bot.get_channel(tracked.channel_id)
 
     if channel is not None:
       result = await self.get_system(None, tracked.name)
@@ -655,15 +659,15 @@ class Faction(DBConnection):
     channel = None
     channel_id = tracked.newsflash_channel_id if tracked.channel_id is None else tracked.channel_id
     if tracked.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(tracked.member_id)
-          channel = await client.start_private_message(member)
+          channel = await member.create_dm()
           break
         except:
           log.exception('report_tracked_{}'.format(what))
     else:
-      channel = client.get_channel(channel_id)
+      channel = bot.get_channel(channel_id)
 
     if channel is None:
       log.warning("Can't get channel for {} {}".format(what, tracked.id))
@@ -761,7 +765,7 @@ class Faction(DBConnection):
           log.debug('Anyone can show own {}s.'.format(what))
           return tracked
         if not tracked.is_private:
-          server = client.get_server(tracked.server_id)
+          server = bot.get_server(tracked.server_id)
           if server is not None:
             member = server.get_member(author.id)
             if member in server.members:
@@ -774,14 +778,14 @@ class Faction(DBConnection):
           log.debug('Anyone can manage own {}s.', what)
           return tracked
         if not tracked.is_private:
-          server = client.get_server(tracked.server_id)
+          server = bot.get_server(tracked.server_id)
           announcer = server.get_member(tracked.member_id)
           member = server.get_member(author.id)
           if member.top_role > announcer.top_role:
             log.debug('Member {} on server {} can edit {}s from lower role.'.format(member.name, server.name, what))
             return tracked
           elif member.top_role == announcer.top_role:
-            if member.top_role >= member.server.me.top_role:
+            if member.top_role >= member.guild.me.top_role:
               log.debug('Member {} with role {} on server {} can edit {}s.'.format(member.name, member.top_role.name, server.name, what))
               return tracked
 
@@ -789,9 +793,8 @@ class Faction(DBConnection):
       if tracked.is_private:
         log.debug('Anyone can create a private {}.'.format(what))
         return tracked
-      server = client.get_server(tracked.server_id)
+      server = bot.get_server(tracked.server_id)
       member = server.get_member(tracked.member_id)
-      bot_member = server.get_member(client.user.id)
       if bot.lower_role_than(member, strict = True):
         log.debug('Member {} with role {} on server {} can create {}s.'.format(member.name, member.top_role.name, server.name, what))
         return tracked
@@ -826,10 +829,7 @@ class Faction(DBConnection):
     parsed = {}
     name = None
 
-    if editing:
-      command = 'edit'
-    else:
-      command = 'create'
+    if not editing:
       parsed['member_id'] = message.author.id
 
     log.debug('{} track {}'.format(what, args))
@@ -868,7 +868,7 @@ class Faction(DBConnection):
             ok = True
           else:
             parsed[k] = message.channel.id
-            parsed[s] = message.channel.server.id
+            parsed[s] = message.channel.guild.id
             ok = True
         elif param == 'private':
           parsed[k] = 'private'
@@ -878,7 +878,7 @@ class Faction(DBConnection):
           channel = bot.parse_channel(param, author = message.author, voice_ok = False)
           if channel is not None:
             parsed[k] = channel.id
-            parsed[s] = channel.server.id
+            parsed[s] = channel.guild.id
             ok = True
         else:
           break
@@ -940,7 +940,7 @@ class Faction(DBConnection):
         else:
           channel = bot.parse_channel(parsed['channel_id'], author = message.author, voice_ok = False)
           newsflash_channel = bot.parse_channel(parsed['newsflash_channel_id'], author = message.author, voice_ok = False)
-          if channel.server != newsflash_channel.server:
+          if channel.guild != newsflash_channel.guild:
             log.info('News flash channel must be on same server.')
             await bot.say(message.channel, 'Flash channel server!')
             return None
@@ -1128,15 +1128,15 @@ class Faction(DBConnection):
   async def remember_channel_for_news_flash(self, system, tracked, announce):
     channel = None
     if tracked.is_private:
-      for server in client.servers:
+      for server in client.guilds:
         try:
           member = server.get_member(tracked.member_id)
-          channel = await client.start_private_message(member)
+          channel = await member.create_dm()
           break
         except:
           pass
     else:
-      channel = client.get_channel(tracked.newsflash_channel_id)
+      channel = bot.get_channel(tracked.newsflash_channel_id)
     if channel is not None:
       channels = announce.get(system, set())
       channels.add(channel)
similarity index 95%
rename from plugin/facts/facts.py
rename to plugins/facts/facts.py
index 33b555c..3ad5822 100644 (file)
@@ -7,7 +7,7 @@ import bot
 
 class Facts(object):
   def __init__(self):
-    self.path = os.path.dirname(__file__)[(len(os.getcwd()) + 1):]
+    self.path = os.path.split(__file__)[0]
     with open(os.sep.join([self.path, 'catfacts.txt']), 'r') as fd:
       self.catFacts = fd.readlines()
     with open(os.sep.join([self.path, 'cat_haiku.txt']), 'r') as fd:
similarity index 95%
rename from plugin/feeds/feeds.py
rename to plugins/feeds/__init__.py
index 9c4f987..e247df0 100644 (file)
@@ -1,6 +1,6 @@
 import asyncio
 import bs4
-import discord_unofficial as discord
+import discord
 import feedparser
 import json
 import math
@@ -32,7 +32,7 @@ class Feeds(DBConnection):
     self.dbh.commit()
 
   def get_all_feeds(self, client, servers = []):
-    for row in self.get_all_from_table(client, 'feeds', servers):
+    for row in self.get_all_from_table(client, 'feeds', servers, snowflake = True):
       yield row
 
   def get_feeds(self, client):
@@ -70,34 +70,38 @@ class Feeds(DBConnection):
       return k
 
   async def set_rss_permissions(self, channel_id):
-    channel = client.get_channel(channel_id)
+    channel = bot.get_channel(channel_id)
     if channel is None:
       log.warning("Can't get channel {} for RSS feeds.".format(channel_id))
       return
     role = None
     # My highest role.
-    role = channel.server.me.top_role
+    role = channel.guild.me.top_role
     if role is not None:
       # Permissions for bot.
       overwrite = discord.PermissionOverwrite()
-      overwrite.read_messages = True
-      overwrite.read_message_history = True
-      overwrite.send_messages = True
-      overwrite.send_tts_messages = True
-      overwrite.manage_messages = True
-      overwrite.attach_files = True
+      overwrite.update(
+        read_messages = True,
+        read_message_history = True,
+        send_messages = True,
+        send_tts_messages = True,
+        manage_messages = True,
+        attach_files = True
+      )
       await bot.overwrite_permissions(channel, role, overwrite, strict = False)
     # Permissions for @everyone.
     overwrite = discord.PermissionOverwrite()
-    overwrite.read_messages = True
-    overwrite.read_message_history = True
-    overwrite.send_messages = False
-    overwrite.send_tts_messages = False
-    await bot.overwrite_permissions(channel, channel.server.default_role, overwrite)
+    overwrite.update(
+      read_messages = True,
+      read_message_history = True,
+      send_messages = False,
+      send_tts_messages = False
+    )
+    await bot.overwrite_permissions(channel, channel.guild.default_role, overwrite)
 
   async def do_rss(self, feeds):
-    channel_id = str(feeds[0]['channel_id'])
-    channel = client.get_channel(channel_id)
+    channel_id = feeds[0]['channel_id']
+    channel = bot.get_channel(channel_id)
     if not channel:
       log.warning("Can't get channel {} for feed.".format(channel_id))
       return
@@ -156,7 +160,7 @@ class Feeds(DBConnection):
       limit = max(len(all_entries) * 2, int(bot.get('message_limit') * 1.5))
 
       digests = []
-      async for message in client.logs_from(channel, limit = limit):
+      async for message in channel.history(limit = limit):
         digest = bot.digest(message.content)
         log.debug('Saw previously posted RSS with digest {}'.format(digest))
         if digest not in digests:
@@ -295,7 +299,7 @@ class Feeds(DBConnection):
     # Anyone on the server can show details of a feed.
     if command == 'show':
       if feed is not None:
-        server = client.get_server(feed['server_id'])
+        server = bot.get_server(feed['server_id'])
         if server is not None:
           member = server.get_member(author.id)
           if member in server.members:
@@ -304,14 +308,14 @@ class Feeds(DBConnection):
 
     if command in ['create', 'edit', 'delete', 'schedule']:
       if feed is not None:
-        server = client.get_server(feed['server_id'])
+        server = bot.get_server(feed['server_id'])
         member = server.get_member(author.id)
         if bot.lower_role_than(member):
           log.debug('Member {} with role {} on server {} can manage feeds.'.format(member.name, member.top_role, server.name))
           return feed
 
     if command in ['time']:
-      if bot.is_admin(member, member.server):
+      if bot.is_admin(member, member.guild):
         return True
 
     await cat.hiss(channel)
@@ -323,7 +327,7 @@ class Feeds(DBConnection):
       return
 
     results = []
-    for feed in self.get_all_feeds(client, self.get_message_servers(client, message)):
+    for feed in self.get_all_feeds(client, bot.get_message_servers(message)):
       text = '{} **{}** url {} in <#{}>'.format(feed['description'], feed['id'], feed['url'], feed['channel_id'])
       results.append(text)
 
@@ -479,11 +483,11 @@ class Feeds(DBConnection):
       return None
 
     if 'channel_id' in parsed:
-      channel = client.get_channel(parsed['channel_id'])
+      channel = bot.get_channel(parsed['channel_id'])
       if not channel:
         await bot.say(message.channel, 'Invalid channel!')
         return None
-      server = channel.server
+      server = channel.guild
       parsed['server_id'] = server.id
 
     if editing:
similarity index 93%
rename from plugin/greetings/greetings.py
rename to plugins/greetings/__init__.py
index 0c2a822..c4b1e00 100644 (file)
@@ -1,5 +1,5 @@
 import asyncio
-import discord_unofficial as discord
+import discord
 import re
 import shlex
 
@@ -26,12 +26,12 @@ class Greetings(DBConnection):
 
   async def on_ready(self):
     for greeting in self.get_greetings(client):
-      channel = client.get_channel(greeting['channel_id'])
+      channel = bot.get_channel(greeting['channel_id'])
       if channel is None:
         log.warning("Can't get channel for greeting {}!".format(greeting['id']))
         continue
       log.debug('Greeting in {}.'.format(channel.name))
-      for member in channel.server.members:
+      for member in channel.guild.members:
         result = await self.maybe_greet(member, channel = channel, greeting = greeting)
         if result:
           greeting['sound'] = None
@@ -46,25 +46,26 @@ class Greetings(DBConnection):
     if not after.bot:
       return
 
-    return
+    log.debug('{} status was {} now {}.'.format(bot.canonicalise(after), before.status, after.status))
 
-    log.debug('{}#{} status was {} now {}.'.format(after.name, after.discriminator, before.status, after.status))
+    # XXX: Disable excessive hissing at other bots
+    return
 
     if discord.Status.offline not in [before.status, after.status]:
       return
 
     try:
       servers = []
-      for server in client.servers:
+      for server in client.guilds:
         if server.get_member(after.id):
           servers.append(server.id)
       if not len(servers):
-        log.debug("{}#{} isn't on any shared servers!".format(after.name, after.discriminator))
+        log.debug("{} isn't on any shared servers!".format(bot.canonicalise(after)))
         return
       for channel_id in [greeting['channel_id'] for greeting in list(self.get_greetings(client))]:
-        channel = client.get_channel(channel_id)
+        channel = bot.get_channel(channel_id)
         if not bot.shared_with(after, channel):
-          log.debug("{}#{} isn't in channel {} on server {}".format(after.name, after.discriminator, channel.name, server.name))
+          log.debug("{} isn't in channel {} on server {}".format(bot.canonicalise(after), channel.name, server.name))
           continue
         await bot.maybe_say(channel, 'hiss! {}'.format(after.mention), probability = 0.1)
         return
@@ -88,13 +89,13 @@ class Greetings(DBConnection):
       yield greeting
 
   def get_greeting(self, client, id):
-    return self.get_from_table(client, 'greetings', id)
+    return self.get_from_table(client, 'greetings', id, snowflake = True)
 
   def get_greetings(self, client):
     now = self.now()
     cursor = self.query("select id, server_id, channel_id, voice_id, sound, message, role_id, maximum from greetings where bot_id=? and enabled='true'", [client.user.id])
     for row in cursor.fetchall():
-      yield dict(row)
+      yield self.snowflake_row(row)
     self.close_db()
 
   def create_greeting(self, client, **args):
@@ -122,7 +123,7 @@ class Greetings(DBConnection):
     # Anyone on the server can show details of a greeting.
     if command == 'show':
       if greeting is not None:
-        server = client.get_server(greeting['server_id'])
+        server = bot.get_server(greeting['server_id'])
         if server is not None:
           member = server.get_member(author.id)
           if member in server.members:
@@ -131,7 +132,7 @@ class Greetings(DBConnection):
 
     if command in ['create', 'edit', 'delete', 'schedule']:
       if greeting is not None:
-        server = client.get_server(greeting['server_id'])
+        server = bot.get_server(greeting['server_id'])
         member = server.get_member(author.id)
         if bot.lower_role_than(member):
           log.debug('Member {} with role {} on server {} can manage greetings.'.format(member.name, member.top_role.name, server.name))
@@ -146,8 +147,8 @@ class Greetings(DBConnection):
       return
 
     results = []
-    for greeting in self.get_all_greetings(client, self.get_message_servers(client, message)):
-      server = client.get_server(greeting['server_id'])
+    for greeting in self.get_all_greetings(client, bot.get_message_servers(message)):
+      server = bot.get_server(greeting['server_id'])
       text = '**{}** in <#{}>'.format(greeting['id'], greeting['channel_id'])
       if greeting['role_id']:
         text += ' assign &{}'.format(greeting['role_id'])
@@ -254,7 +255,7 @@ class Greetings(DBConnection):
         elif param == 'here':
           if arg != 'in':
             break
-          if message.channel.is_private:
+          if bot.is_private(message.channel):
             log.warning("Can't greet in private channel.")
             break
           else:
@@ -309,11 +310,11 @@ class Greetings(DBConnection):
       return None
 
     if 'channel_id' in parsed:
-      channel = client.get_channel(parsed['channel_id'])
+      channel = bot.get_channel(parsed['channel_id'])
       if not channel:
         await bot.say(message.channel, 'Invalid channel!')
         return None
-      server = channel.server
+      server = channel.guild
       parsed['server_id'] = server.id
 
     if 'message' not in parsed:
@@ -528,14 +529,14 @@ class Greetings(DBConnection):
     channels = [channel] if channel is not None else client.get_all_channels()
     for channel in channels:
       # Don't greet in private channel.
-      if channel.is_private:
+      if bot.is_private(channel):
         log.debug('Not a channel to greet: {} is private.'.format(channel))
         continue
 
       for greeting in greetings:
         # Ensure the greeting applies to this server.
-        if greeting['server_id'] != member.server.id:
-          log.debug('Not a server to greet: {}.'.format(member.server.name))
+        if greeting['server_id'] != member.guild.id:
+          log.debug('Not a server to greet: {}.'.format(member.guild.name))
           continue
 
         # Ensure the greeting applies to this channel.
@@ -555,11 +556,11 @@ class Greetings(DBConnection):
             role = member_role
             break
         if role is not None:
-          log.debug('Not greeting {} with {} role already.'.format(member, role_name))
+          log.debug('Not greeting {} with {} role already.'.format(member, role.name))
           continue
 
         # Don't greet a member who isn't here.
-        if member not in channel.server.members:
+        if member not in channel.guild.members:
           log.debug('Not greeting {} in {}: User not in server memberlist.'.format(member, channel.name))
           continue
 
@@ -584,7 +585,7 @@ class Greetings(DBConnection):
     return True
 
   async def greet(self, member, greeting):
-    channel = client.get_channel(greeting['channel_id'])
+    channel = bot.get_channel(greeting['channel_id'])
     if channel is None:
       log.warning("Can't get channel for greeting {}!".format(greeting['id']))
       return
@@ -601,14 +602,14 @@ class Greetings(DBConnection):
       return
     if 'voice_id' not in greeting:
       return
-    voice = client.get_channel(greeting['voice_id'])
+    voice = bot.get_channel(greeting['voice_id'])
     if voice is None:
       log.warning("Can't get voice channel for greeting {}!".format(greeting['id']))
       return
     await bot.play_sound(voice, greeting['sound'])
 
   async def set_role(self, member, greeting):
-    for server in client.servers:
+    for server in client.guilds:
       if server.id != greeting['server_id']:
         continue
       for role in server.roles:
similarity index 74%
rename from plugin/heading/heading.py
rename to plugins/heading/__init__.py
index 489fe9b..94de5d9 100644 (file)
@@ -14,32 +14,28 @@ class Heading(object):
   def valid_commands(self):
     return ['heading']
 
-  @asyncio.coroutine
-  def handle_command(self, message, command, raw):
+  async def handle_command(self, message, command, raw):
     if command not in self.valid_commands():
       return PluginCommand.ignored
-    yield from self.handle_heading(message, shlex.split(raw)[1:])
+    await self.handle_heading(message, shlex.split(raw)[1:])
     return PluginCommand.exclusive
 
-  @asyncio.coroutine
-  def handle_help(self, message, command, *args):
-    yield from self.help_heading(message, *args)
+  async def handle_help(self, message, command, *args):
+    await self.help_heading(message, *args)
 
-  @asyncio.coroutine
-  def handle_heading(self, message, args):
+  async def handle_heading(self, message, args):
     if not len(args):
       return
 
     command = args[0].lower()
     if command == 'help':
-      yield from self.help_heading(message)
+      await self.help_heading(message)
     elif command in ['from', 'to']:
-      yield from self.navigate(message, args)
+      await self.navigate(message, args)
     else:
-      yield from bot.say(message.channel, '?')
+      await bot.say(message.channel, '?')
 
-  @asyncio.coroutine
-  def help_heading(self, message, *args):
+  async def help_heading(self, message, *args):
     lines = [
       '{} can assist you with orbital cruise.'.format(client.user.mention),
       '',
@@ -57,10 +53,9 @@ class Heading(object):
       'I can calculate the exact heading to fly between your current position and target co-ordinates.  Send me a command like this:',
       '```heading from 44.08 -33.12 to -27.14 -130.95```'
     ]
-    yield from bot.say_many(message.channel, lines)
+    await bot.say_many(message.channel, lines)
 
-  @asyncio.coroutine
-  def navigate(self, message, args):
+  async def navigate(self, message, args):
     log.debug('Parsing {}'.format(args))
 
     parsed = {}
@@ -87,7 +82,7 @@ class Heading(object):
         except ValueError:
           break
       else:
-        yield from bot.say(message.channel, 'What is {}?'.format(arg))
+        await bot.say(message.channel, 'What is {}?'.format(arg))
         return
 
       if ok:
@@ -97,24 +92,24 @@ class Heading(object):
 
     for k in ['from', 'to']:
       if k not in parsed:
-        yield from bot.say(message.channel, '{} where?'.format(k))
+        await bot.say(message.channel, '{} where?'.format(k))
         return None
 
     if not ok:
       log.info('Failed to parse heading.  Got: {}'.format(parsed))
-      yield from bot.say(message.channel, '{}?'.format(arg))
+      await bot.say(message.channel, '{}?'.format(arg))
       return None
 
     for k in ['from', 'to']:
       latitude, longitude = parsed[k]
       if latitude < -90 or latitude > 90:
-        yield from bot.say(message.channel, 'Invalid latitude!')
+        await bot.say(message.channel, 'Invalid latitude!')
         return
       if longitude < -180 or longitude > 180:
-        yield from bot.say(message.channel, 'Invalid longitude!')
+        await bot.say(message.channel, 'Invalid longitude!')
         return
 
-    yield from bot.say(message.channel, self.calculate_bearing(parsed['from'], parsed['to']))
+    await bot.say(message.channel, self.calculate_bearing(parsed['from'], parsed['to']))
 
   def calculate_bearing(self, start, end):
     log.info('Calculating bearing from {} to {}.'.format(start, end))
@@ -122,6 +117,6 @@ class Heading(object):
     slat, slong = [math.radians(l) for l in start]
     elat, elong = [math.radians(l) for l in end]
 
-    x = (math.cos(slat) * math.sin(elat)) - (math.sin(slat) * math.cos(elat) * math.cos(elong - slong));
+    x = (math.cos(slat) * math.sin(elat)) - (math.sin(slat) * math.cos(elat) * math.cos(elong - slong))
     y = math.sin(elong - slong) * math.cos(elat)
     return int(math.degrees(math.atan2(y, x)) % 360)
similarity index 63%
rename from plugin/mention_hint/mention_hint.py
rename to plugins/mention_hint/__init__.py
index f8e8297..5829857 100644 (file)
@@ -9,7 +9,7 @@ class Mention_hint(object):
     return 'Hint how to @mention the bot.'
 
   async def not_our_message(self, message):
-    if re.match(r'\b{}\b'.format(message.server.me.name), message.content):
-      await bot.maybe_say(message.channel, message.server.me.mention, probability = 0.1, wake = False)
+    if re.search(r'\b{}\b'.format(message.guild.me.name), message.content):
+      await bot.maybe_say(message.channel, message.guild.me.mention, probability = 0.1, wake = False)
       return PluginCommand.handled
     return PluginCommand.ignored
similarity index 100%
rename from plugin/o7/o7.py
rename to plugins/o7/__init__.py
similarity index 78%
rename from plugin/play/play.py
rename to plugins/play/__init__.py
index 7e1eab0..153223c 100644 (file)
@@ -19,18 +19,23 @@ class Play(object):
   def valid_commands(self):
     return ['play', 'tts']
 
+  def secret_commands(self):
+    return ['tts:']
+
   async def handle_command(self, message, command, raw):
-    if command not in self.valid_commands():
+    if command not in self.valid_commands() and command not in self.secret_commands():
       return PluginCommand.ignored
     if command == 'play':
       await self.play(message, shlex.split(raw)[1:])
-    elif command == 'tts':
+    elif command in ['tts', 'tts:']:
       parts = raw.split(':')
       args = shlex.split(parts[0])[1:]
+      if not args and command == 'tts:':
+        args = ['to', 'me']
       if len(parts) > 1:
         text = ':'.join(parts[1:]).strip()
-        await self.play(message, args, text)
-      elif args[0] == 'help':
+        await self.play(message, args, text = text)
+      elif len(args) > 0 and args[0] == 'help':
         await self.help_tts(message)
       else:
         await bot.say(message.channel, 'what?')
@@ -46,12 +51,13 @@ class Play(object):
     lines = [
       'Play audio from a URL:',
       '```',
-      'play URL',
-      'play URL in CHANNEL',
-      'play URL to MEMBER',
+      'play URL [as ALIAS]',
+      'play URL in CHANNEL [as ALIAS]',
+      'play URL to MEMBER [as ALIAS]',
       '```',
       'If you do not specify a #channel I will play to the channel you are in.',
-      "If you specify `to` a user I will play to that user's channel instead."
+      "If you specify `to` a user I will play to that user's channel instead.",
+      'If you specify `as ALIAS` I will report that I am playing that ALIAS instead of the URL.  If the ALIAS consists of multiple words you should quote it, eg `as "cool sound"`.'
     ]
     await bot.say_many(message.channel, lines)
 
@@ -59,11 +65,12 @@ class Play(object):
     lines = [
       'Text-to-speech:',
       '```',
-      'tts in CHANNEL [ARGS]: MESSAGE',
-      'tts to MEMBER [ARGS]: MESSAGE',
+      'tts in CHANNEL [ARGS] [as ALIAS]: MESSAGE',
+      'tts to MEMBER [ARGS] [as ALIAS]: MESSAGE',
       '',
       'If you do not specify a #channel I will play to the channel you are in.',
       "If you specify `to` a user I will play to that user's channel instead."
+      'If you specify `as ALIAS` I will report that I am playing that ALIAS instead of _text-to-speech_.  If the ALIAS consists of multiple words you should quote it, eg `as "text to speech"`.'
       '```',
       'The `ARGS` to `tts` are one or more of:',
       '',
@@ -73,11 +80,13 @@ class Play(object):
       '',
       '`pitch 0-99` default `50`',
       '',
-      '`speed 80-450` default `175`'
+      '`speed 80-450` default `175`',
+      ''
+
     ]
     await bot.say_many(message.channel, lines)
 
-  async def play(self, message, args, text = None):
+  async def play(self, message, args, name = None, text = None):
     if not len(args):
       await bot.maybe_say(message.channel, 'what?', probability = 0.5)
       return False
@@ -131,6 +140,10 @@ class Play(object):
         if param is not None:
           tts[arg] = param
           ok = True
+      elif arg == 'as':
+        if param is not None:
+          name = param
+          ok = True
       elif not text:
         # arg is lowercase!
         url = args[i]
@@ -153,16 +166,15 @@ class Play(object):
         await bot.say(message.channel, 'where?')
       return False
 
-    server = voice.server
+    server = voice.guild
     member = server.get_member(message.author.id)
     if bot.is_admin(member, server) or member.top_role >= server.me.top_role:
-      await bot.wake_up()
       if text:
         log.info('Text-to-speech request by {} in {} on {}: {}'.format(message.author, voice.name, server.name, text))
-        result = await bot.tts(voice, text, tts)
+        result = await bot.tts(voice, text, tts, name = name)
       else:
         log.info('Sound {} requested by {} in {} on {}'.format(url, message.author, voice.name, server.name))
-        result = await bot.play_sound(voice, url)
+        result = await bot.play_sound(voice, url, name = name)
       if not result:
         await cat.yelp(message.channel)
     else:
similarity index 98%
rename from plugin/prune/__init__.py
rename to plugins/prune/__init__.py
index f7bed65..02ac61e 100644 (file)
@@ -131,10 +131,10 @@ class Prune(object):
         await bot.say(message.channel, 'Role?')
         return None
     if 'server' not in parsed:
-      if message.channel.is_private:
+      if bot.is_private(message.channel):
         await bot.say(message.channel, 'Which server?')
         return None
-      parsed['server'] = message.channel.server
+      parsed['server'] = message.guild
     elif parsed['server'] is None:
       await bot.say(message.channel, 'Server?')
       return None
@@ -175,6 +175,7 @@ class Prune(object):
           await cat.yelp(message.channel)
       if pruned is not None:
         await bot.say(message.channel, str(pruned))
+
   async def find_members(self, days = None, kick = False, only = False, role = None, server = None):
     cutoff = datetime.datetime.utcnow() - datetime.timedelta(days)
 
similarity index 100%
rename from plugin/rng/rng.py
rename to plugins/rng/__init__.py
similarity index 88%
rename from plugin/role/role.py
rename to plugins/role/__init__.py
index 6a9360c..3ecbe53 100644 (file)
@@ -1,5 +1,5 @@
 import asyncio
-import discord_unofficial as discord
+import discord
 import shlex
 
 from db import DBConnection
@@ -10,7 +10,10 @@ import cat
 class ManagedRole(object):
   def __init__(self, row):
     for k, v in dict(row).items():
-      setattr(self, k, v)
+      if k.endswith('_id'):
+        setattr(self, k, bot.snowflake(v))
+      else:
+        setattr(self, k, v)
     if not hasattr(self, 'above_role_id'):
       self.above_role_id = None
     if not hasattr(self, 'below_role_id'):
@@ -141,13 +144,13 @@ class Role(DBConnection):
       params.append(role.id)
     cursor = self.query(sql, params)
     for row in cursor.fetchall():
-      yield ManagedRole(dict(row))
+      yield ManagedRole(self.snowflake_row(row))
     self.close_db()
 
   def get_managed_role(self, role):
     managed_roles = list(self.get_managed_roles(role))
     if managed_roles is None or not len(managed_roles):
-      log.info('Role {} on {} is not managed'.format(role.name, role.server.name))
+      log.info('Role {} on {} is not managed'.format(role.name, role.guild.name))
       return None
     return managed_roles[0]
 
@@ -158,7 +161,7 @@ class Role(DBConnection):
       await bot.say(message.channel, '?')
       return (None, None, None)
 
-    if role.is_everyone:
+    if bot.is_everyone(role):
       log.info("Can't make &everyone assignable!")
       await bot.say(message.channel, 'everyone?')
       return (None, None, None)
@@ -170,29 +173,29 @@ class Role(DBConnection):
     if managed_role is None:
       return (None, None, cat.yelp)
 
-    member = role.server.get_member(message.author.id)
+    member = role.guild.get_member(message.author.id)
     can_manage = True
     if command is not None:
-      can_manage = await self.can_manage_roles(message.author, message.channel, command, role.server)
+      can_manage = await self.can_manage_roles(message.author, message.channel, command, role.guild)
 
     if managed_role.above_role_id:
-      above_role = self.get_role_on_server(managed_role.above_role_id, role.server)
+      above_role = self.get_role_on_server(managed_role.above_role_id, role.guild)
       if not above_role:
-        log.warning("Can't find above role {} on server {}".format(managed_role.above_role_id, role.server.name))
+        log.warning("Can't find above role {} on server {}".format(managed_role.above_role_id, role.guild.name))
         return (None, None, cat.yelp)
       if command is None and member.top_role <= above_role:
         if member.top_role < above_role or not above_inclusive:
-          log.info('Member {} needs at least role {} to assign {} on {}'.format(member.name, above_role.name, role.name, role.server.name))
+          log.info('Member {} needs at least role {} to assign {} on {}'.format(member.name, above_role.name, role.name, role.guild.name))
           return (role, managed_role, cat.hiss)
 
     if managed_role.below_role_id:
-      below_role = self.get_role_on_server(managed_role.below_role_id, role.server)
+      below_role = self.get_role_on_server(managed_role.below_role_id, role.guild)
       if not below_role:
-        log.warning("Can't find below role {} on server {}".format(managed_role.below_role_id, role.server.name))
+        log.warning("Can't find below role {} on server {}".format(managed_role.below_role_id, role.guild.name))
         return (None, None, cat.yelp)
       if command is None and member.top_role >= below_role:
         if member.top_role > below_role or not below_inclusive:
-          log.info('Member {} needs at least role {} to assign {} on {}'.format(member.name, below_role.name, role.name, role.server.name))
+          log.info('Member {} needs at least role {} to assign {} on {}'.format(member.name, below_role.name, role.name, role.guild.name))
           return (role, managed_role, cat.hiss)
 
     log.debug('Managing role {} for {}'.format(role.name, member.name))
@@ -202,7 +205,7 @@ class Role(DBConnection):
     roles = []
     hiss = False
     for managed_role in self.get_managed_roles():
-      server = client.get_server(managed_role.server_id)
+      server = bot.get_server(managed_role.server_id)
       result = await self.can_manage_roles(message.author, message.channel, 'list', server, False)
       if not result:
         hiss = True
@@ -211,9 +214,9 @@ class Role(DBConnection):
         log.debug('Not showing managed role {} on server {} to user {} not on that server'.format(managed_role.role_id, server.name, message.author.name))
         hiss = True
         continue
-      if not message.channel.is_private:
-        if message.channel.server.id != managed_role.server_id:
-          log.debug('Not showing managed role {} on server {} to authorised user {} in public channel {} on {}'.format(managed_role.role_id, server_name, message.author.name, message.channel.name, message.channel.server.name))
+      if not bot.is_private(message.channel):
+        if message.channel.guild.id != managed_role.server_id:
+          log.debug('Not showing managed role {} on server {} to authorised user {} in public channel {} on {}'.format(managed_role.role_id, server_name, message.author.name, message.channel.name, message.channel.guild.name))
           continue
       role = self.get_role_on_server(managed_role.role_id, server)
       if not role:
@@ -222,7 +225,7 @@ class Role(DBConnection):
       roles.append(role)
 
     if len(roles):
-      await bot.say_many(message.channel, ['"{}" on {}'.format(role.name, role.server.name) if message.channel.is_private else role.name for role in roles])
+      await bot.say_many(message.channel, ['"{}" on {}'.format(role.name, role.guild.name) if bot.is_private(message.channel) else role.name for role in roles])
     elif hiss:
       await cat.hiss(message.channel)
     else:
@@ -239,15 +242,15 @@ class Role(DBConnection):
       log.error("Can't find role {} to manage!".format(args[0]))
       await cat.yelp(message.channel)
       return
-    result = await self.can_manage_roles(message.author, message.channel, 'manage', role.server)
+    result = await self.can_manage_roles(message.author, message.channel, 'manage', role.guild)
     if not result:
       return
-    if role.is_everyone:
+    if bot.is_everyone(role):
       await bot.say(message.channel, "Can't manage everyone!")
       return
 
     parsed = {
-      'server_id': role.server.id,
+      'server_id': role.guild.id,
       'role_id': role.id
     }
 
@@ -270,7 +273,7 @@ class Role(DBConnection):
 
       if arg == 'above':
         if param:
-          above_role = await bot.parse_role(param, author = message.author, server = role.server)
+          above_role = await bot.parse_role(param, author = message.author, server = role.guild)
           if above_role:
             id = above_role.id
             if inclusive:
@@ -279,18 +282,18 @@ class Role(DBConnection):
               parsed['below_inclusive'] = True
             else:
               seen['above'] = True
-              if not above_role.is_everyone:
+              if not bot.is_everyone(above_role):
                 parsed['above_role_id'] = id
             ok = True
             inclusive = False
       elif arg == 'below':
         if param:
-          below_role = await bot.parse_role(param, author = message.author, server = role.server)
+          below_role = await bot.parse_role(param, author = message.author, server = role.guild)
           if below_role:
             id = below_role.id
             if inclusive:
               seen['not-below'] = True
-              if not above_role.is_everyone:
+              if not bot.is_everyone(above_role):
                 parsed['above_role_id'] = id
                 parsed['above_inclusive'] = True
             else:
@@ -331,12 +334,12 @@ class Role(DBConnection):
       if self.update_table(client, 'roles', **parsed):
         await bot.say(message.channel, role.name)
       else:
-        log.error('Failed to update manage role {} on {}'.format(role.name, role.server.name))
+        log.error('Failed to update manage role {} on {}'.format(role.name, role.guild.name))
         await cat.yelp(message.channel)
     elif self.insert_into_table(client, 'roles', **parsed):
       await bot.say(message.channel, role.name)
     else:
-      log.error('Failed to manage role {} on {}'.format(role.name, role.server.name))
+      log.error('Failed to manage role {} on {}'.format(role.name, role.guild.name))
       await cat.yelp(message.channel)
 
   async def unmanage_role(self, message, args):
@@ -350,7 +353,7 @@ class Role(DBConnection):
     if not role:
       return
 
-    result = await self.can_manage_roles(message.author, message.channel, 'unmanage', role.server)
+    result = await self.can_manage_roles(message.author, message.channel, 'unmanage', role.guild)
     if not result:
       return
 
@@ -369,21 +372,21 @@ class Role(DBConnection):
     if not role:
       return
 
-    result = await self.can_manage_roles(message.author, message.channel, 'assign', role.server)
+    result = await self.can_manage_roles(message.author, message.channel, 'assign', role.guild)
     if not result:
       return
 
-    member = role.server.get_member(message.author.id)
+    member = role.guild.get_member(message.author.id)
     if role in member.roles:
-      log.info('Member {} already has role {} on {}'.format(member.name, role.name, role.server.name))
+      log.info('Member {} already has role {} on {}'.format(member.name, role.name, role.guild.name))
       return
 
     try:
-      await client.add_roles(member, role)
-      log.info('Added role {} to {} on {}'.format(role.name, role.server.name, member.name))
+      await member.add_roles(role, reason = 'Self-assignable role')
+      log.info('Added role {} to {} on {}'.format(role.name, role.guild.name, member.name))
       await cat.purr(message.channel)
     except:
-      log.error("Couldn't add role {} on {} to {}".format(role.name, role.server.name, member.name))
+      log.error("Couldn't add role {} on {} to {}".format(role.name, role.guild.name, member.name))
       await cat.yelp(message.channel)
 
   async def unassign_role(self, message, args):
@@ -397,20 +400,20 @@ class Role(DBConnection):
     if not role:
       return
 
-    result = await self.can_manage_roles(message.author, message.channel, 'unassign', role.server)
+    result = await self.can_manage_roles(message.author, message.channel, 'unassign', role.guild)
     if not result:
       return
 
-    member = role.server.get_member(message.author.id)
+    member = role.guild.get_member(message.author.id)
     if role not in member.roles:
-      log.info("Member {} doesn't have role {} on {}".format(member.name, role.name, role.server.name))
+      log.info("Member {} doesn't have role {} on {}".format(member.name, role.name, role.guild.name))
       return
 
     try:
-      await client.remove_roles(member, role)
-      log.info('Removed role {} from {} on {}'.format(role.name, role.server.name, member.name))
+      await member.remove_roles(role, reason = 'Self-assignable role')
+      log.info('Removed role {} from {} on {}'.format(role.name, role.guild.name, member.name))
       await cat.purr(message.channel)
     except:
-      log.error("Couldn't remove role {} on {} from {}".format(role.name, role.server.name, member.name))
+      log.error("Couldn't remove role {} on {} from {}".format(role.name, role.guild.name, member.name))
       await cat.yelp(message.channel)
 
similarity index 85%
rename from plugin/shopping/shopping.py
rename to plugins/shopping/__init__.py
index 477436d..0193697 100644 (file)
@@ -12,7 +12,7 @@ import eddb
 
 class Shopping(object):
   def __init__(self):
-    self.path = os.path.dirname(__file__)[(len(os.getcwd()) + 1):]
+    self.path = os.path.split(__file__)[0]
     self.COMMODITIES = os.sep.join([self.path, 'commodities.json'])
     self.COMMODITY_KEYS = ['id', 'average_price', 'is_rare', 'name']
     self.MODULES = os.sep.join([self.path, 'modules.json'])
@@ -26,15 +26,13 @@ class Shopping(object):
   def description(self):
     return 'Advise where to buy ships, modules or commodities.'
 
-  @asyncio.coroutine
-  def handle_command(self, message, command, raw):
+  async def handle_command(self, message, command, raw):
     if command not in self.valid_commands():
       return PluginCommand.ignored
-    yield from self.handle_shopping(message, shlex.split(raw)[1:])
+    await self.handle_shopping(message, shlex.split(raw)[1:])
     return PluginCommand.exclusive
 
-  @asyncio.coroutine
-  def handle_help(self, message, *args):
+  async def handle_help(self, message, *args):
     lines = [
       '{} can help you find ships, modules and commodities.'.format(client.user.mention),
       '```shopping [discounted] [large] [orbital] [near SYSTEM] [SHIPS] [MODULES] [COMMODITIES]```',
@@ -68,43 +66,51 @@ class Shopping(object):
       '',
       'Example: `shopping near Bugayaman Gold`'
     ]
-    yield from bot.say_many(message.channel, lines)
+    await bot.say_many(message.channel, lines)
 
-  @asyncio.coroutine
-  def handle_shopping(self, message, args):
+  async def handle_shopping(self, message, args):
     if not len(args):
       return
 
     if args[0].lower() == 'help':
-      yield from self.handle_help(message)
+      await self.handle_help(message)
       return
 
-    yield from client.send_typing(message.channel)
-
+    lines = []
     params = {}
-    names = []
-    i = 0
-    while i < len(args):
-      arg = args[i].lower()
-      log.info(arg)
-      if arg == 'discounted':
-        params['discount'] = True
-      elif arg == 'near':
-        try:
-          params['system'] = args[i + 1]
-          i += 1
-        except IndexError:
-          yield from bot.say(message.channel, 'Where?')
-          return
-      elif arg == 'large':
-        params['large'] = True
-      elif arg == 'orbital':
-        params['orbital'] = True
-      else:
-        names.append(args[i])
-      i += 1
-
-    yield from self.find_shopping_stations(message, names, **params)
+    async with message.channel.typing() as typing:
+      log.info('meow')
+      names = []
+      i = 0
+      while i < len(args):
+        arg = args[i].lower()
+        log.info(arg)
+        if arg == 'discounted':
+          params['discount'] = True
+        elif arg == 'near':
+          try:
+            params['system'] = args[i + 1]
+            i += 1
+          except IndexError:
+            ret = False
+            lines = ['Where?']
+            break
+        elif arg == 'large':
+          params['large'] = True
+        elif arg == 'orbital':
+          params['orbital'] = True
+        else:
+          names.append(args[i])
+        i += 1
+        
+      if not lines:
+        ret, lines = self.find_shopping_stations(message, names, **params)
+
+      log.info('meow')
+    if lines:
+      await bot.say_many(message.channel, lines, typing = typing)
+
+    return ret
 
   def filter_key(self, entry, key, value = None):
     if key is None:
@@ -240,16 +246,14 @@ class Shopping(object):
         break
     return ships
 
-  @asyncio.coroutine
-  def build_shopping_list(self, message, names):
+  def build_shopping_list(self, names):
     commodities = list(self.get_commodities_by_name(names))
     modules = list(self.get_modules_by_name(names))
     ships = list(self.get_ships_by_name(names))
     found = set([c['search'] for c in commodities] + [m['search'] for m in modules] + [s['search'] for s in ships])
     if len(found) < len(names):
       missing = [name for name in names if name not in found]
-      yield from bot.say(message.channel, '{}?'.format(', '.join(missing)))
-      return None
+      return (None, '{}?'.format(', '.join(missing)))
     params = {}
     if len(commodities):
       params['sellsCommodityIds'] = ','.join([str(c['id']) for c in commodities])
@@ -257,7 +261,7 @@ class Shopping(object):
       params['sellingModulesIdsString'] = ','.join([str(m['id']) for m in modules])
     if len(ships):
       params['sellingShipIdsString'] = ','.join([str(s['id']) for s in ships])
-    return params
+    return (params, None)
 
   def format_station(self, station, params, reference_coords = None):
     text = ''
@@ -291,12 +295,11 @@ class Shopping(object):
       text += ' updated {}'.format(bot.ago(updated))
     return text
 
-  @asyncio.coroutine
   def find_shopping_stations(self, message, names, *, discount = False, large = False, orbital = False, system = None):
     log.info('Shopping for {}; discount={}; system={}.'.format(', '.join(names), discount, system))
-    params = yield from self.build_shopping_list(message, names)
+    params, complaint = self.build_shopping_list(names)
     if params is None:
-      return False
+      return (False, [complaint])
 
     reference_coords = None
 
@@ -305,12 +308,10 @@ class Shopping(object):
       if len(systems) > 1:
         if systems[0]['name'].lower() != system.lower():
           log.warning('More than one system matched {}.'.format(system))
-          yield from bot.say(message.channel, 'Which system? {}', ','.join([s['name'] for s in systems]))
-          return False
+          return (False, ['Which system? {}', ','.join([s['name'] for s in systems])])
       elif not len(systems):
         log.warning('No system matched {}.'.format(system))
-        yield from bot.say(message.channel, 'Where is {}?'.format(system))
-        return False
+        return (False, ['Where is {}?'.format(system)])
       params['referenceSystemId'] = systems[0]['id']
       if 'x' in systems[0]:
         reference_coords = { 'x': systems[0]['x'], 'y': systems[0]['y'], 'z': systems[0]['z'] }
@@ -335,6 +336,6 @@ class Shopping(object):
       lines.append(self.format_station(station, params, reference_coords))
     if not len(lines):
       lines = ['*shrugs*']
-    if not message.channel.is_private:
+    if not bot.is_private(message.channel):
       lines.insert(0, message.author.mention)
-    yield from bot.say_many(message.channel, lines)
+    return (True, lines)
similarity index 90%
rename from plugin/typing/typing.py
rename to plugins/typing/__init__.py
index 77abda4..7c95171 100644 (file)
@@ -19,7 +19,7 @@ class Typing(object):
     return 'User is typing...'
 
   def key(self, user, channel):
-    return '@'.join([user.id, channel.id])
+    return '{}@{}'.format(user.id, channel.id)
 
   def delete(self, key):
     try:
@@ -59,7 +59,7 @@ class Typing(object):
     times = self.times(key)
     times.append(when)
     if len(times) > self.threshold and max(times) - min(times) >= self.min_ttl:
-      await bot.maybe_say(channel, '**{}** is typing ...'.format(user.nick if user.nick is not None else user.name), probability = self.probability)
+      await bot.maybe_say(channel, '**{}** is typing...'.format(user.nick if user.nick is not None else user.name), probability = self.probability)
       self.cleanup()
     else:
       log.debug('Remembering {}'.format(key))
index b539b3c..b6b963d 100644 (file)
@@ -1,5 +1,5 @@
 bs4
-discord.py-unofficial[voice]==0.17.1
+discord.py[voice]==1.5.1
 feedparser
 matplotlib
 pexpect