Initial plugin system with feeds as the first plugin.
authorCMDR furrycat <elite@furrycat.net>
Tue, 25 Oct 2016 14:44:05 +0000 (15:44 +0100)
committerCMDR furrycat <elite@furrycat.net>
Tue, 25 Oct 2016 14:44:05 +0000 (15:44 +0100)
app.py [new file with mode: 0644]
bot.py [changed mode: 0755->0644]
db.py
plugins/__init__.py [new file with mode: 0644]
plugins/feeds.py [new file with mode: 0644]

diff --git a/app.py b/app.py
new file mode 100644 (file)
index 0000000..eea831e
--- /dev/null
+++ b/app.py
@@ -0,0 +1,2053 @@
+#!/usr/bin/env python
+
+import asyncio
+import datetime
+import discord
+import hashlib
+import io
+import logging
+import math
+import multiprocessing
+import os
+import pexpect
+import pytz
+import random
+import re
+import shlex
+import sys
+import time
+from PIL import Image
+
+from db import DBConnection
+from enum import Enum
+from plugins import Plugins
+import bot
+
+class Identify(Enum):
+  member = 1
+  role = 2
+  channel = 4
+
+  def string(instance):
+    if type(instance) == discord.member.Member:
+      return 'Member'
+    if type(instance) == discord.member.User:
+      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)
+      return 'Channel'
+
+if sys.version_info >= (3, 0):
+  import urllib.request
+else:
+  import urllib2
+
+db = DBConnection()
+plugins = Plugins()
+
+token_file = 'TOKEN'
+if len(sys.argv) > 1:
+  token_file = sys.argv[1]
+
+admins = []
+admin_role = 'Cat'
+fd = open('ADMINS', 'r')
+for line in fd.readlines():
+  admin = line.strip()
+  admins.append(admin)
+fd.close()
+
+fd = open(token_file, 'r')
+token = fd.readline().strip()
+fd.close()
+logging.basicConfig(level = logging.DEBUG if bot.get('debug') else logging.WARN, format = '%(asctime)s %(name)s: %(levelname)s: %(funcName)s@%(module)s:%(lineno)d: %(message)s')
+discordlog = logging.getLogger('discord')
+log = logging.getLogger('catbot')
+bot.log = log
+log.setLevel(logging.DEBUG if bot.get('debug') else logging.INFO)
+
+idletime = multiprocessing.Value('i', 900)
+threads_ready = multiprocessing.Value('b', False)
+
+bots = {
+  # catbot
+  '225207358450696192': {
+    'greetings': [
+      { 'channel': 222685317885329408, 'message': 'Welcome to Delta Squadron, take a look at #delta_rules and make yourself at home in the public lobby. If interested in joining please PM a member of the High Command or Council for assistance', 'voice': 222103336483028993, 'sound': 'meow.wav' }
+    ]
+  },
+  # catbot beta
+  '229879865686360064': {
+    'greetings': [
+      { 'channel': 225217541922881538, 'message': 'Welcome to Catbot', 'voice': 231296897938227201, 'sound': 'meow.wav' }
+    ]
+  }
+}
+
+def get_bot_variable(k, v = None):
+  bot = bots.get(str(client.user.id), [])
+  if k in bot:
+    return bot.get(k, v)
+  return v
+
+def wait_for_prompt(p):
+  p.expect('EDI> ')
+
+@asyncio.coroutine
+def say_expect(message, stdout):
+  if stdout:
+    log.debug(stdout)
+    result = yield from bot.say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not message.channel.is_private else '', stdout))
+  else:
+    result = yield from bot.say(message.channel, 'Sorry!')
+  return result
+
+@asyncio.coroutine
+def not_admin(message):
+  log.warning('Not admin: {} id {}'.format(message.author, message.author.id))
+  yield from bot.maybe_say(message.channel, 'hiss!')
+
+@asyncio.coroutine
+def is_admin(server, member):
+  if server is None:
+    return member.id in admins
+  for role in server.roles:
+    if role.name != admin_role:
+      continue
+    if role in member.roles:
+      return True
+  return False
+
+def create_avatar(url, bgfile, fgfile):
+  # Get Delta images.
+  bg = Image.open(bgfile)
+  fg = Image.open(fgfile)
+  if not bg or not fg:
+    log.warning("Can't open avatar foreground and/or background image!")
+    return None
+  cutoff = float(bg.width) / 1.5
+
+  # Get provided image.
+  fd = open_url(url)
+  if fd is None:
+    log.debug('Failed to open URL {} for avatar creation'.format(url))
+    return None
+  try:
+    image = Image.open(fd)
+  except:
+    log.warning('Failed to get image from {}'.format(url))
+    return None
+  fd.close()
+  if image is None:
+    return None
+
+  # Crop square.
+  box = None
+  if image.height > image.width:
+    top = math.floor((image.height - image.width) / 2)
+    box = 0, top, image.width - 1, top + image.width - 1
+  elif image.height < image.width:
+    left = math.floor((image.width - image.height) / 2)
+    box = left, 0, left + image.height - 1, image.height - 1
+  if box is not None:
+    cropped = image.crop(box)
+    image = cropped.copy()
+
+  # Scale leaving space for background.
+  if image.height > cutoff:
+    image.thumbnail((cutoff, cutoff))
+  elif image.height < cutoff:
+    width = math.floor(image.width * (float(fg.width) / cutoff))
+    height = math.floor(image.height * (float(fg.height) / cutoff))
+    bg.thumbnail((width, height))
+    fg.thumbnail((width, height))
+
+  # Pad with blank pixels.
+  padded = Image.new(bg.mode, fg.size)
+  left = math.floor((padded.width - image.width) / 2)
+  top = math.floor((padded.height - image.height) / 2)
+  padded.paste(image, (left, top, left + image.width, top + image.height))
+
+  # Merge them.
+  result = Image.alpha_composite(Image.alpha_composite(bg, padded), fg)
+  b = io.BytesIO()
+  result.save(b, 'PNG')
+  return b.getvalue()
+
+def bucky_avatar(url):
+  return create_avatar(url, 'buckybg.png', 'buckyfg.png')
+
+def delta_avatar(url):
+  return create_avatar(url, 'DELTA-BG.png', 'DELTA-FURRYTEMPLATE.png')
+
+@asyncio.coroutine
+def identify(destination, target):
+  do = Identify.member.value | Identify.role.value | Identify.channel.value
+  channel_id = None
+  role_id = None
+  member_id = None
+
+  if target:
+    m = re.match(r'<(@&?|#)(\d+)>', target)
+    if m is not None:
+      target = None
+      prefix = m.group(1)
+      id = m.group(2)
+      if prefix == '@':
+        member_id = str(id)
+        do = Identify.member.value
+      elif prefix == '@&':
+        role_id = str(id)
+        do = Identify.role.value
+      elif prefix == '#':
+        channel_id = str(id)
+        do = Identify.channel.value
+    else:
+      m = re.match(r'--(member|role|channel)s?', target)
+      if m is not None:
+        target = None
+        suffix = m.group(1)
+        if suffix == 'member':
+          do = Identify.member.value
+        elif suffix == 'role':
+          do = Identify.role.value
+        elif suffix == 'channel':
+          do = Identify.channel.value
+
+  results = []
+  if do & Identify.member.value:
+    if member_id:
+      for server in client.servers:
+        member = server.get_member(member_id)
+        if member is not None:
+          results.append(member)
+          break
+    else:
+      for member in client.get_all_members():
+        if target:
+          parts = target.lower().split('#')
+          if member.name.lower() != parts[0]:
+            continue
+          if len(parts) > 1 and member.discriminator != parts[1]:
+            continue
+        results.append(member)
+  if do & Identify.role.value:
+    if role_id:
+      for server in client.servers:
+        for role in server.roles:
+          if role.id == role_id:
+            results.append(role)
+            break
+    else:
+      for server in client.servers:
+        for role in server.roles:
+          if target and role.name.lower() != target.lower():
+            continue
+          results.append(role)
+  if do & Identify.channel.value:
+    if channel_id:
+      channel = client.get_channel(channel_id)
+      if channel is not None:
+        results.append(channel)
+    else:
+      for channel in client.get_all_channels():
+        if target and channel.name.lower() != target.lower():
+          continue
+        results.append(channel)
+
+  yield from bot.say_many(destination, ['{} {} {}'.format(Identify.string(result), result.id, result.name) for result in set(results)])
+
+@asyncio.coroutine
+def show_help(message, *args):
+  if len(args):
+    command = args[0]
+  else:
+    command = None
+
+  if command is None:
+    lines = [
+      '<@{}>, the cat-like robot from the 34th century of the future, recognises these commands (and more):'.format(client.user.id),
+      '```',
+      'announce',
+      'coords',
+      'bling',
+      'close_to',
+      'distance',
+      'edts',
+      'find',
+      'fuel_usage',
+      'galmath',
+      'id',
+      'raikogram',
+      'time',
+      '```',
+      'Send `help COMMAND` for help on a specific command.'
+    ]
+  elif command == 'announce' or command == 'announcement':
+    if len(args) > 1:
+      yield from help_announcements(message, args[1])
+    else:
+      yield from help_announcements(message)
+    return
+  elif command == 'feed' or command == 'news':
+    if len(args) > 1:
+      yield from help_feeds(message, args[1])
+    else:
+      yield from help_feeds(message)
+    return
+  elif command == 'bling':
+    lines = [
+      'Convert an image into a Delta Squadron or Buckyball Racing Club avatar suitable for use on Discord.',
+      '```',
+      'bling --bucky http://example.com/image.png',
+      'bling http://example.com/image.png',
+      '```',
+      'Blings the image at the given URL.',
+      "Note that I can't bling certain URLs, and I will yelp at you if you ask me to.  In that case you can download the image yourself and attach it to a message to me.",
+      '',
+      '```',
+      'bling --bucky @user',
+      'bling @user',
+      '```',
+      'Blings the existing avatar of the tagged user.',
+      'Needless to say, only users who have set an avatar can have it blinged.',
+      '',
+      '```',
+      'bling --bucky',
+      'bling',
+      '```',
+      'With no arguments but with an image attached to the message, I will bling the image.  With no arguments and no attachment I will bling *your* avatar.'
+    ]
+  elif command == 'help':
+    lines = ['grr!']
+  elif command == 'id':
+    lines = [
+      'Print the Discord IDs of member, roles or channels known to me.',
+      '```',
+      'id MEMBER',
+      'id ROLE',
+      'id CHANNEL',
+      '```',
+      'Prints the ID of the member, role or channel with the given name.',
+      "I can only identify someone or something if I share a server.",
+      'Useful for `announce create` to specify a `voice` channel, which must be done by ID.',
+      '',
+      '```',
+      'id --members',
+      'id --roles',
+      'id --channels',
+      'id',
+      '```',
+      'Identify all MEMBERs, ROLEs or CHANNELs known to me.',
+      "If you don't give any arguments I'll identify everyone and everything I know about.",
+      'Note that if I share more than one server with a member, that member will be listed twice with two IDs!',
+      "Also note that because of the Discord message length limit I'll probably have to split my reply into multiple posts."
+    ]
+  elif command == 'time':
+    lines = [
+      'Print the time in UTC.',
+      '```time```',
+      'Useful for `announce create` or to check in-game time.'
+    ]
+  elif command == 'treat':
+    lines = [
+      "You can give me a treat if you like.  I may (or may not) show gratitude.",
+      'Probably not.'
+    ]
+  elif command in ['coords', 'close_to', 'distance', 'edts', 'find', 'fuel_usage', 'galmath', 'raikogram']:
+    params = modules['edi']
+    fn = params['fn']
+    args = params['args']
+    yield from fn(message, 'help', 'help {}'.format(command), *args)
+    return
+  else:
+    lines = ['*shrugs*']
+  yield from bot.say_many(message.channel, lines)
+
+@asyncio.coroutine
+def do_commands(message, command, raw, non_admin):
+  if not non_admin:
+    result = yield from is_admin(message.server, message.author)
+    if not result:
+      yield from not_admin(message)
+      return
+
+  log.debug('Command: {}'.format(raw))
+
+  if command == 'avatar':
+    # avatar
+    url = None
+    m = re.match(r'\bavatar\s+(\S+)', raw, re.IGNORECASE)
+    if m is not None:
+      url = m.group(1)
+    elif len(message.attachments):
+      url = message.attachments[0]['url']
+    if url is None:
+      log.debug('No URL for avatar')
+      yield from bot.say(message.channel, 'zzz', wake = False)
+      return
+    result = yield from set_avatar(url)
+    if not result:
+      yield from bot.say(message.channel, 'yelp!')
+      return
+
+  elif command == 'bling':
+    # bling
+    url = None
+
+    args = shlex.split(raw)
+    fn = delta_avatar
+    for i in range(1, len(args)):
+      arg = args[i].lower()
+      if arg == '--delta':
+        continue
+      elif arg == '--bucky':
+        fn = bucky_avatar
+      else:
+        url = args[i]
+
+    filename = message.author.name
+    mentions = [message.author.mention] if not message.channel.is_private else None
+    if url is not None:
+      m = re.match(r'<@(!?\d+)>', url)
+      if m is not None:
+        other = None
+        id = str(m.group(1))
+        for member in client.get_all_members():
+          if member.id == id:
+            other = member
+        if other is None:
+          log.warning("Can't see requested user {}".format(url))
+          url = None
+        else:
+          if mentions is not None:
+            filename = other.name
+            mentions.append(other.mention)
+          url = other.avatar_url
+    elif len(message.attachments):
+      url = message.attachments[0]['url']
+    else:
+      log.info('Using avatar URL {}'.format(url))
+      url = message.author.avatar_url
+    if url is None:
+      log.debug('No URL for avatar to Deltaify')
+      yield from bot.say(message.channel, 'zzz', wake = False)
+      return
+    yield from client.send_typing(message.channel)
+    data = fn(url)
+    if data is None:
+      yield from bot.say(message.channel, 'yelp!')
+      return
+    yield from client.send_file(message.channel, data, filename = filename + '.png', content = ' '.join(mentions) if mentions is not None else 'purr')
+    return
+
+  elif command == 'debug':
+    # debug
+    m = re.match(r'\bdebug\s+(o(?:ff|n))\b', raw, re.IGNORECASE)
+    if m is None:
+      yield from bot.say(message.channel, 'Sorry!')
+      return
+
+    arg = m.group(1)
+    if arg == 'on':
+      discordlog.setLevel(logging.DEBUG)
+      log.setLevel(logging.DEBUG)
+      db.log_level(logging.DEBUG)
+      plugins.log_level(logging.DEBUG)
+    elif arg == 'off':
+      discordlog.setLevel(logging.WARN)
+      log.setLevel(logging.INFO)
+      db.log_level(logging.WARN)
+      plugins.log_level(logging.INFO)
+
+  elif command == 'delete':
+    # delete
+    if message.channel.is_private:
+      yield from bot.say(message.channel, 'hiss', wake = False)
+      return
+
+    args = {}
+    limit = bot.get('message_limit')
+    m = re.match(r'\bdelete\s+(all|\d+)\b', raw, re.IGNORECASE)
+    if m is not None:
+      if m.group(1) != 'all':
+        limit = int(m.group(1))
+    yield from client.purge_from(message.channel, *args, limit = limit)
+    return
+
+  elif command == 'dryrun':
+    # dryrun
+    m = re.match(r'\bdryrun\s+(o(?:ff|n))\b', raw, re.IGNORECASE)
+    if m is None:
+      yield from bot.say(message.channel, 'Sorry!')
+      return
+
+    arg = m.group(1)
+    if arg == 'on':
+      log.info('DRYRUN mode')
+      bot.set('dryrun', True)
+    elif arg == 'off':
+      log.info('Live mode')
+      bot.set('dryrun', False)
+
+  elif command == 'id':
+    # id
+    m = re.match(r'\bid\s+(.*)', raw, re.IGNORECASE)
+    if m is not None:
+      target = m.group(1)
+    else:
+      target = None
+    yield from identify(message.channel, target)
+
+  elif command == 'help':
+    # help
+    m = re.match(r'\bhelp\s+(.+)', raw, re.IGNORECASE)
+    if m is not None:
+      args = shlex.split(m.group(1))
+      yield from show_help(message, *args)
+    else:
+      yield from show_help(message)
+
+  elif command == 'idle':
+    # idle
+    m = re.match(r'\bidle\s+(\d+)\b', raw, re.IGNORECASE)
+    if m is None:
+      yield from bot.say(message.channel, 'Sorry!')
+      return
+
+    idletime.value = int(m.group(1))
+
+  elif command == 'rss':
+    # rss
+    m = re.match(r'\brss\s+(\d+)\b', raw, re.IGNORECASE)
+    if m is None:
+      yield from bot.say(message.channel, 'Sorry!')
+      return
+
+    rsstime.value = int(m.group(1))
+
+  elif command == 'time':
+    # time
+    yield from bot.say(message.channel, iso8601(int(time.time())))
+    yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+    return
+
+  elif command in ['treat', 'stroke', 'fuss', 'pat']:
+    # treat
+    if command == 'treat':
+      responses = ['om nom nom', '^-^', 'meow!', 'lick']
+    else:
+      responses = ['nuzzle', '^-^', 'meow!', 'mrrp']
+    for response in responses:
+      said = yield from bot.maybe_say(message.channel, response)
+      if said:
+        yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+        return
+
+  yield from bot.maybe_say(message.channel, 'purr', wake = False)
+  yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+
+@asyncio.coroutine
+def do_edi(message, command, raw, p):
+  if command == 'close_to' and not '-m' in raw:
+    raw = 'close_to'
+  if command == 'distance':
+    raw = raw.replace(',', '')
+  log.info(raw)
+  yield from client.send_typing(message.channel)
+  p.sendline(raw)
+  wait_for_prompt(p)
+  yield from bot.say_expect(message, p.before)
+
+@asyncio.coroutine
+def do_fork(message, command, raw):
+  yield from client.send_typing(message.channel)
+  if re.search(r'[^A-Za-z0-9-_."\'\s]', raw):
+    yield from bot.say(message.channel, 'hiss')
+    return
+  p = pexpect.spawnu('./{}'.format(raw))
+  p.expect(pexpect.EOF)
+  yield from bot.say_expect(message, p.before)
+
+@asyncio.coroutine
+def set_role(member, role_name = 'Unassigned', minimum = 1):
+  role = None
+  for server_role in member.server.roles:
+    if server_role.name != role_name:
+      continue
+    role = server_role
+    break
+  if role is None:
+    log.warning("Can't find {} role to assign to {}".format(role_name, member.name))
+    return
+
+  if minimum is not None and len(member.roles) > minimum:
+    return
+
+  if bot.get('dryrun'):
+    log.info('Not assigning {} role to {}'.format(role.name, member.name))
+    return
+  log.info('Adding {} role to {}'.format(role.name, member.name))
+  try:
+    yield from client.add_roles(member, role)
+  except discord.errors.Forbidden:
+    log.warning('Forbidden to add {} role to {}'.format(role.name, member.name))
+
+@asyncio.coroutine
+def set_unassigned(member):
+  result = yield from is_admin(None, member)
+  if result:
+    log.debug('{} is an admin so not setting role {}'.format(member.name, 'Unassigned'))
+    return
+  yield from set_role(member, 'Unassigned')
+
+@asyncio.coroutine
+def greet(channel, member, sound = True):
+  for greeting in get_bot_variable('greetings', []):
+    if greeting['channel'] != int(channel.id):
+      continue
+    message = ' '.join([member.mention, greeting['message']])
+    if bot.get('dryrun'):
+      log.info('Not sending greeting {}'.format(message))
+    else:
+      yield from bot.say(channel, message)
+      if sound:
+        yield from play_greeting(greeting)
+
+@asyncio.coroutine
+def can_manage_announcements(author, channel, command, **args):
+  # Anyone can list announcements.
+  if command == 'list':
+    log.debug('Anyone can list announcements.')
+    return True
+
+  if 'id' in args:
+    announcement = db.get_announcement(client, args['id'])
+  elif 'create' in args:
+    announcement = args['create']
+  else:
+    announcement = None
+
+  # Anyone on the server can show details of an announcement.
+  if command == 'show':
+    if announcement is not None:
+      if announcement['member_id'] == author.id:
+        log.debug('Anyone can show own announcements.')
+        return announcement
+      if announcement['channel_id'] != 'private':
+        server = client.get_server(announcement['server_id'])
+        if server is not None:
+          member = server.get_member(author.id)
+          if member in server.members:
+            log.debug('Member {} on server {} can show announcement {}.'.format(member.name, server.name, announcement['id']))
+            return announcement
+
+  if command in ['edit', 'delete', 'schedule']:
+    if announcement is not None:
+      if announcement['member_id'] == author.id:
+        log.debug('Anyone can manage own announcements.')
+        return announcement
+      if announcement['channel_id'] != 'private':
+        server = client.get_server(announcement['server_id'])
+        announcer = server.get_member(announcement['member_id'])
+        announcer_role = bot.highest_role(announcer.roles)
+        member = server.get_member(author.id)
+        member_role = bot.highest_role(member.roles)
+        if member_role.position > announcer_role.position:
+          log.debug('Member {} on server {} can edit announcements from lower role.'.format(member.name, server.name))
+          return announcement
+        elif member_role.position == announcer_role.position:
+          if member_role.position == bot.highest_role(server.roles).position:
+            log.debug('Member {} with role {} on server {} can edit announcements.'.format(member.name, member_role.name, server.name))
+            return announcement
+
+  if command == 'create':
+    if announcement['channel_id'] == 'private':
+      log.debug('Anyone can schedule a private announcement.')
+      return announcement
+    server = client.get_server(announcement['server_id'])
+    member = server.get_member(announcement['member_id'])
+    bot = server.get_member(client.user.id)
+    member_role = bot.highest_role(member.roles)
+    bot_role = bot.highest_role(bot.roles)
+    if member_role.position > bot_role.position:
+      log.debug('Member {} with role {} on server {} can create announcements.'.format(member.name, member_role.name, server.name))
+      return announcement
+
+  yield from bot.say(channel, 'hiss!')
+  return False
+
+@asyncio.coroutine
+def help_announcements(message, command = None):
+  if command is None:
+    lines = [
+      'Commands to manage announcements are:',
+      '```',
+      'asap',
+      'create',
+      'delete',
+      'edit',
+      'list',
+      'pause',
+      'show',
+      'resume',
+      '```',
+      'Send `announce help COMMAND` for help on a specific command.'
+    ]
+  elif command == 'create':
+    lines = [
+      'Create a new announcement.',
+      '```announce create OPTIONS: MESSAGE```',
+      'Use the `OPTIONS` to define when and where to send the announcement.  The `MESSAGE` can be omitted if you just want me to play a sound.',
+      'You need to include the **:** after the OPTIONS.  Anything you include after it will be part of the MESSAGE!',
+      '',
+      'Here are the OPTIONS you can use:',
+      '',
+      '```every INTERVAL```',
+      'Specify the interval at which the announcement will be sent.  You can include **d**ays, **h**ours, **m**inutes or **s**econds.  `every 6h` means every six hours.  `every 10m30s` means every ten minutes and thirty seconds.',
+      '',
+      '```from DATE```',
+      "Don't start giving the announcement until at least this date.  The date must be specified as YYYY-MM-DDThh:mm:ss, eg {}, and is in **UTC**.  Send me the `time` command and I'll tell you the current time in UTC.".format(iso8601(int(time.time()))),
+      '',
+      '```to DATE```',
+      'Stop giving the announcement after this date.  See the notes on `from` for how to specify the date.',
+      '',
+      '```tell MENTION```',
+      'Mention `@user`, `@role` or `#channel` in the announcement.',
+      '',
+      '```in CHANNEL```',
+      'Send the announcement to the specified #channel.',
+      '',
+      '```voice CHANNEL```',
+      'Play a sound to the specified voice #CHANNEL.',
+      '',
+      '```sound FILE```',
+      "File to play.  I won't play sounds in private messages!",
+      '',
+      'I also accept some shortcuts:',
+      '',
+      "`tell me` means to mention you in the announcement.  If you don't specify a channel with `in` the announcement will be sent in a private message.",
+      '',
+      "`in here` means to send the message to the channel in which you sent `announce create`.  If you send it in a private message the announcement will also be private.",
+      '',
+      '`once` is equivalent to `interval 0` and means to give the announcement just once.',
+      '',
+      '`at DATE` is equivalent to `from DATE to DATE once` and means to give the announcement just once at the specified time.',
+      '',
+      'Example: `announce create every 12h in #channel: This message will be sent twice a day.`'
+    ]
+  elif command == 'delete':
+    lines = [
+      'Delete the announcement with the given ID.',
+      '```announcement delete ID```',
+      'You can only delete your own announcements or public announcements created by someone in a lower role.'
+    ]
+  elif command == 'edit':
+    lines = [
+      'Edit an announcement.',
+      '```announcement edit ID OPTIONS: MESSAGE```',
+      'Change one or more `OPTIONS` or the `MESSAGE` for the announcement with the given ID.',
+      'See the help for `announce create` for details of the OPTIONS you can set.',
+      'Some options can be set to *none* to delete that option.',
+      'For instance `to none` means that the announcement will no longer have an end date.'
+    ]
+  elif command == 'help':
+    lines = ['grr!']
+  elif command == 'list':
+    lines = [
+      'List all announcements, one `ID` per line.  You can use the `ID` in other commands.',
+      "When listing IDs to a public channel I won't show IDs for announcements that are for channels on another server.  Send `announce list` to me in a private message to see them.",
+      "I'll never show the IDs of another user's announcements, even in private."
+    ]
+  elif command == 'show':
+    lines = [
+      'Show the announcement with the given ID.',
+      '```announcement show ID```',
+      'I will tell you the details in a format which you could copy and paste to create a new announcement.',
+      "I'll never show details of another user's private announcements."
+    ]
+  elif command in ['asap', 'pause', 'resume']:
+    lines = [
+      'Schedule the announcement with the given ID.',
+      '```',
+      'announcement asap ID',
+      'announcement pause ID',
+      'announcement resume ID',
+      '```',
+      'Use `asap` to give the announcement as soon as possible regardless of scheduling.',
+      'Use `pause` and `resume` to put an announcement on hold temporarily.'
+    ]
+  else:
+    lines = ['*shrugs*']
+  yield from bot.say_many(message.channel, lines)
+
+@asyncio.coroutine
+def list_announcements(message):
+  result = yield from can_manage_announcements(message.author, message.channel, 'list')
+  if not result:
+    return
+  servers = []
+  if message.channel.is_private:
+    servers.append('private')
+    for server in client.servers:
+      if message.author in server.members:
+        servers.append(server)
+  else:
+    servers = [message.channel.server]
+
+  results = []
+  for announcement in db.get_all_announcements(client, servers):
+    if announcement['channel_id'] == 'private' and announcement['member_id'] != message.author.id:
+      continue
+    text = '**{}**'.format(announcement['id'])
+    if announcement['channel_id'] == 'private':
+      text += ' in private'
+    else:
+      server = client.get_server(announcement['server_id'])
+      member = server.get_member(announcement['member_id'])
+      text += ' by {}'.format(member.name)
+    if announcement['channel_id']:
+      if announcement['channel_id'] != 'private':
+        text += ' in <#{}>'.format(announcement['channel_id'])
+    if announcement['voice_id']:
+      if announcement['sound']:
+        text += ' sound {}'.format(announcement['sound'])
+      if announcement['voice_id'] != announcement['channel_id']:
+        text += ' in <#{}>'.format(announcement['voice_id'])
+    if announcement['message']:
+      short = announcement['message'][:100]
+      if short != announcement['message']:
+        short += '...'
+      text += ': {}'.format(short.replace('\n', ' '))
+    results.append(text)
+
+  if len(results):
+    yield from bot.say_many(message.channel, results)
+  else:
+    yield from bot.say(message.channel, '*shrugs*')
+
+def iso8601(timestamp):
+  return datetime.datetime.utcfromtimestamp(timestamp).isoformat()[:19] + 'Z'
+
+@asyncio.coroutine
+def show_announcement(message, id):
+  announcement = yield from can_manage_announcements(message.author, message.channel, 'show', id = id)
+  if not announcement:
+    return
+  lines = []
+
+  if announcement['channel_id'] == 'private':
+    for server in client.servers:
+      try:
+        member = server.get_member(announcement['member_id'])
+      except:
+        pass
+  else:
+    server = client.get_server(announcement['server_id'])
+    member = server.get_member(announcement['member_id'])
+  text = '**{}announcement {} by {}'.format('private ' if announcement['channel_id'] == 'private' else '', id, member.name)
+  now = int(time.time())
+  if announcement['last_spoke']:
+    offset = announcement['last_spoke']
+    text += ' last given at {}'.format(iso8601(announcement['last_spoke']))
+  else:
+    offset = now
+  if announcement['start_date']:
+    if announcement['start_date'] > now:
+      offset = announcement['start_date']
+      text += ' scheduled for {}'.format(iso8601(announcement['start_date']))
+  if announcement['end_date']:
+    if announcement['end_date'] < now:
+      offset = None
+      text += ' has expired'
+  if offset is not None and offset <= now:
+    if announcement['interval']:
+      text += ' scheduled for {}'.format(iso8601(offset + announcement['interval']))
+  if announcement['asap'] == 'true':
+    text += ' will be given ASAP'
+  if announcement['probability'] < 0:
+    text += ' paused'
+  text += '**'
+  lines.append(text)
+
+  text = 'announce create'
+  if announcement['start_date']:
+    text += ' from "{}"'.format(iso8601(announcement['start_date']))
+  if announcement['end_date']:
+    text += ' to "{}"'.format(iso8601(announcement['end_date']))
+  if announcement['interval']:
+    text += ' every {}'.format(unparse_seconds(int(announcement['interval'])))
+  mention = announcement['mention']
+  if mention:
+    if mention == 'everyone' or mention == 'here':
+      text += ' tell @{}'.format(mention)
+    else:
+      if message.channel.is_private and mention[0] == '&':
+        # Can't mention a role in a private channel.
+        text += ' tell @{}'.format(mention)
+      else:
+        text += ' tell <@{}>'.format(mention)
+  if announcement['channel_id'] != announcement['voice_id']:
+    if announcement['channel_id'] == 'private':
+      text += ' in private'
+    else:
+      text += ' in <#{}>'.format(announcement['channel_id'])
+  if announcement['voice_id']:
+    text += ' voice <#{}>'.format(announcement['voice_id'])
+    if announcement['sound']:
+      text += ' play "{}"'.format(announcement['sound'])
+  if abs(announcement['probability']) < 1.0:
+    text += ' probability {}'.format(abs(announcement['probability']))
+
+  if announcement['message']:
+    text += ':'
+  lines.append(text)
+  if announcement['message']:
+    lines.append(announcement['message'])
+
+  yield from bot.say(message.channel, '\n'.join(lines))
+
+# Map a database key to a syntax parameter.
+def announcement_key(k):
+  d = {
+    'channel_id': 'in',
+    'end_date': 'to',
+    'interval': 'every',
+    'mention': 'tell',
+    'start_date': 'from',
+    'voice_id': 'voice'
+  }
+  if k in d:
+    return d[k]
+  else:
+    return k
+
+# Map a database key to a syntax parameter.
+def feed_key(k):
+  d = {
+    'channel_id': 'in',
+    'description': 'title'
+  }
+  if k in d:
+    return d[k]
+  else:
+    return k
+
+@asyncio.coroutine
+def parse_announcement(message, raw, editing = False):
+  # We want to split by colon but don't want to count any which are part of dates or URLs.
+  m = re.match(r'(?:[^:]*\s+(?:(?:at|from|to)\s+"?\d\d\d\d-?\d\d-?\d\d(?:T|\s+)\d\d:?\d\d:?\d\dZ?"?|sound\s+https?:\S+))+', raw, re.IGNORECASE)
+  if m is not None:
+    parts = raw[len(m.group(0)):].split(':')
+    parts[0] = m.group(0) + parts[0]
+  else:
+    parts = raw.split(':')
+  params = parts[0]
+  if len(parts) > 1:
+    text = ':'.join(parts[1:]).strip()
+  else:
+    text = None
+
+  parsed = {}
+  if editing:
+    command = 'edit'
+  else:
+    command = 'create'
+    parsed['member_id'] = message.author.id
+  if text:
+    parsed['message'] = text
+
+  log.debug('announce {} params {} text {}'.format(command, params, text))
+  args = shlex.split(params)
+  log.info(args)
+  ok = False
+  i = 0
+  while i < len(args):
+    arg = args[i].lower()
+    if i > len(args) - 1:
+      break
+    try:
+      param = args[i + 1]
+    except IndexError:
+      param = None
+    log.info('{}: {}={}'.format(i, arg, param))
+    ok = False
+
+    if arg == command:
+      # UUID missing 'id'.
+      if editing and re.match(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', param):
+        parsed['id'] = param
+      else:
+        i -= 1
+      ok = True
+    elif arg == 'id':
+      if editing:
+        parsed['id'] = param
+        ok = True
+      else:
+        break
+    elif arg == 'once':
+      parsed['interval'] = 0
+      i -= 1
+      ok = True
+    elif arg in ['at', 'from', 'to']:
+      k = 'end_date' if arg == 'to' else 'start_date'
+      if editing and param == 'none':
+        parsed[k] = None
+        ok = True
+      else:
+        m = re.match(r'(\d\d\d\d)-?(\d\d)-?(\d\d)(?:T|\s+)(\d\d):?(\d\d):?(\d\d)Z?', param)
+        if m is not None:
+          parsed[k] = int(datetime.datetime(*([int(n) for n in m.groups()] + [0, pytz.UTC])).timestamp())
+          ok = True
+        else:
+          break
+      if arg == 'at':
+        parsed['end_date'] = parsed['start_date']
+        parsed['interval'] = 0
+    elif arg == 'every':
+      seconds = parse_seconds(param)
+      if seconds is not None:
+        parsed['interval'] = seconds
+        ok = True
+      else:
+        break
+    elif arg == 'tell':
+      if editing and param == 'none':
+        parsed['mention'] = None
+        ok = True
+      elif param == 'me':
+        parsed['mention'] = message.author.id
+        if 'channel_id' not in parsed:
+          parsed['channel_id'] = 'private'
+        ok = True
+      else:
+        m = re.match(r'<@((?:&?|#)\d+)>', param)
+        if m is None:
+          m = re.match(r'@((?:&?|#)\d+)', param)
+          if m is None:
+            m = re.match('r@(everyone|here)', param)
+        if m is not None:
+          parsed['mention'] = m.group(1)
+          ok = True
+        else:
+          break
+    elif arg in ['in', 'voice']:
+      k = 'channel_id' if arg == 'in' else 'voice_id'
+      if editing and param == 'none':
+        parsed['mention'] = None
+        ok = True
+      elif param == 'here':
+        if arg != 'in':
+          break
+        if message.channel.is_private:
+          parsed[k] = 'private'
+          ok = True
+        else:
+          parsed[k] = message.channel.id
+          ok = True
+      elif param == 'private':
+        if arg != 'in':
+          break
+        parsed[k] = 'private'
+        ok = True
+      else:
+        m = re.match(r'<#(\d+)>', param)
+        if m is None:
+          m = re.match(r'#(\d+)', param)
+        if m is not None:
+          parsed[k] = m.group(1)
+          ok = True
+        else:
+          m = re.match(r'#(.+)', param)
+          if m is None:
+            break
+          # Look for a channel with that name.
+          for channel in client.get_all_channels():
+            if channel.name == m.group(1):
+              parsed[k] = channel.id
+              ok = True
+              break
+    elif arg == 'sound':
+      if editing and param == 'none':
+        parsed['mention'] = None
+        ok = True
+      else:
+        parsed['sound'] = param
+        ok = True
+    elif arg == 'probability':
+      parsed['probability'] = param
+      ok = True
+    else:
+      yield from bot.say(message.channel, 'What is {}?'.format(arg))
+      return
+
+    if ok:
+      i += 2
+    else:
+      break
+
+  if not ok:
+    log.info('Failed to parse announcement.  Got: {}'.format(parsed))
+    yield from bot.say(message.channel, '{}?'.format(arg))
+    return None
+
+  if 'channel_id' in parsed:
+    if parsed['channel_id'] == 'private':
+      parsed['server_id'] = 'private'
+      if 'mention' in parsed:
+        if parsed['mention'] != message.author.id:
+          yield from bot.say(message.channel, "Can't mention someone else in private message!")
+          return None
+      parsed['mention'] = None
+      if 'voice_id' in parsed or 'sound' in parsed:
+        yield from bot.say(message.channel, 'No sounds for private messages!')
+        return None
+    else:
+      channel = client.get_channel(parsed['channel_id'])
+      if not channel:
+        yield from bot.say(message.channel, 'Invalid channel!')
+        return None
+      server = channel.server
+      parsed['server_id'] = server.id
+
+  if 'start_date' in parsed and 'end_date' in parsed:
+    if parsed['start_date'] > parsed['end_date']:
+      yield from bot.say(message.channel, "Start date must not be before end date!")
+      return None
+
+  if editing:
+    if 'id' not in parsed:
+      yield from bot.say(message.channel, 'Missing ID!')
+      return None
+  else:
+    if 'channel_id' not in parsed:
+      if 'voice_id' in parsed:
+        parsed['channel_id'] = parsed['voice_id']
+      else:
+        yield from bot.say(message.channel, 'Missing channel!')
+        return None
+
+  return parsed
+
+@asyncio.coroutine
+def create_announcement(message, raw):
+  # announcement create [params]: <text>
+  create = yield from parse_announcement(message, raw)
+  if create is None:
+    return
+
+  announce = yield from can_manage_announcements(message.author, message.channel, 'create', create = create)
+  if not announce:
+    return
+
+  id = db.create_announcement(client, **create)
+  if id:
+    create['id'] = id
+    log.info('Created announcement: {}'.format(create))
+    yield from bot.say(message.channel, id)
+    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+  else:
+    log.info('Failed to create announcement: {}'.format(create))
+    yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def edit_announcement(message, raw):
+  update = yield from parse_announcement(message, raw, True)
+  if update is None:
+    return
+
+  id = update['id']
+  del(update['id'])
+  announce = yield from can_manage_announcements(message.author, message.channel, 'edit', id = id)
+  if not announce:
+    return
+
+  if not len(update.keys()):
+    yield from bot.say(message.channel, '?')
+    return
+
+  if db.update_announcement(client, id, **update):
+    log.info('Edited announcement {}: {}'.format(id, update))
+    yield from bot.say(message.channel, ', '.join([announcement_key(k) for k in update]))
+    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+  else:
+    log.info('Failed to edit announcement {}: {}'.format(id, update))
+    yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def delete_announcement(message, id):
+  announcement = yield from can_manage_announcements(message.author, message.channel, 'delete', id = id)
+  if not announcement:
+    return
+  if bot.get('dryrun'):
+    log.info('Not deleting announcement {}'.format(id))
+  else:
+    log.info('Deleting announcement {}'.format(id))
+    if db.delete_announcement(client, id):
+      yield from bot.say(message.channel, 'purr')
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def schedule_announcement(message, id, **args):
+  announcement = yield from can_manage_announcements(message.author, message.channel, 'schedule', id = id)
+  if not announcement:
+    return
+
+  update = {}
+
+  # Pause announcement.
+  if 'pause' in args:
+    if args['pause']:
+      update['probability'] = -abs(announcement['probability'])
+    else:
+      update['probability'] = abs(announcement['probability'])
+
+  # ASAP.
+  if 'asap' in args:
+    update['asap'] = 'true'
+
+  if bot.get('dryrun'):
+    log.info('Not updating announcement {}: {}'.format(id, update))
+  else:
+    log.info('Updating announcement {}: {}'.format(id, update))
+    if db.update_announcement(client, id, **update):
+      yield from bot.say(message.channel, 'purr')
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def manage_announcements(message, command, raw):
+  log.debug('Command: {}'.format(raw))
+
+  m = re.match(r'announce(?:ment)?\s+(.+)', raw, re.IGNORECASE | re.DOTALL)
+  if m is None:
+    yield from list_announcements(message)
+    return
+  text = m.group(1)
+  args = shlex.split(text)
+  command = args[0].lower()
+  if len(args) == 1:
+    if command == 'list':
+      yield from list_announcements(message)
+      return
+    elif command == 'help':
+      yield from help_announcements(message)
+      return
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+      return
+
+  if command == 'show':
+    yield from show_announcement(message, args[1])
+  elif command == 'help':
+    yield from help_announcements(message, args[1])
+  elif command == 'delete':
+    yield from delete_announcement(message, args[1])
+  elif command == 'asap':
+    yield from schedule_announcement(message, args[1], asap = True)
+  elif command == 'pause':
+    yield from schedule_announcement(message, args[1], pause = True)
+  elif command == 'resume':
+    yield from schedule_announcement(message, args[1], pause = False)
+  elif command == 'create':
+    yield from create_announcement(message, text)
+  elif command == 'edit':
+    yield from edit_announcement(message, text)
+
+@asyncio.coroutine
+def announce(announcement):
+  update = {}
+  # Adhere to schedule.
+  if announcement['asap'] == 'true':
+    log.info('Announcement {} was requested ASAP'.format(announcement['id']))
+    announcement['probability'] = 1.0
+    update['asap'] = 'false'
+  elif announcement['interval']:
+    if announcement['start_date'] or announcement['last_spoke']:
+      now = int(time.time())
+      if announcement['start_date']:
+        start = announcement['start_date']
+      else:
+        start = announcement['last_spoke']
+      intervals = math.floor((now - start) / announcement['interval'])
+      if intervals:
+        # We missed a schedule.
+        scheduled = start + intervals * announcement['interval']
+        if now - scheduled > max(60, announcement['interval'] / 2):
+          log.info('Announcement {} should have been given at {}'.format(announcement['id'], iso8601(scheduled)))
+          if 'digest' in announcement:
+            update['digest'] = announcement['digest']
+            update['last_spoke'] = scheduled
+          db.update_announcement(client, announcement['id'], **update)
+          return
+      # Give the announcement now but set the original schedule.
+      last_spoke = start + (intervals + 1) * announcement['interval']
+      if last_spoke < now:
+        update['last_spoke'] = last_spoke
+        log.info('Forcing last_spoke for announcement {} to {} in line with schedule'.format(announcement['id'], iso8601(update['last_spoke'])))
+  if 'asap' not in update and 'last_spoke' not in update:
+    update['last_spoke'] = int(time.time())
+
+  channel = None
+  channel_id = str(announcement['channel_id'])
+  private = channel_id == 'private'
+  if private:
+    for server in client.servers:
+      try:
+        member = server.get_member(announcement['member_id'])
+        channel = yield from client.start_private_message(member)
+        break
+      except:
+        pass
+  else:
+    channel = client.get_channel(channel_id)
+  if channel is None:
+    log.warning("Can't get channel for announcement {}".format(announcement['id']))
+    # Set last_spoke so we don't spam.
+    db.update_announcement(client, announcement['id'], **update)
+    return
+
+  voice_only = announcement['voice_id'] == announcement['channel_id']
+  already_posted = False
+  if not voice_only:
+    # @everyone mention mangles the digest
+    text = announcement['message']
+    mention = announcement['mention']
+    if mention:
+      if mention in ['everyone', 'here']:
+        text = '@{}\n{}'.format(mention, text)
+      else:
+        text = '<@{}>\n{}'.format(mention, text)
+
+    digest = announcement['digest']
+
+    # Always post private messages.  Try not to spam public announcements.
+    if not private:
+      cutoff = datetime.datetime.utcnow() - datetime.timedelta(0, announcement['interval'])
+      result = yield from client.logs_from(channel, after = cutoff)
+      logs = list(result)
+      # Don't spam the same message in a quiet channel even if it hasn't
+      # been posted since the cutoff.
+      if len(logs) < bot.get('message_limit'):
+        logs = yield from client.logs_from(channel, limit = bot.get('message_limit'))
+      for message in logs:
+        raw = re.sub(r'^(<?@\S+\s)+', '', message.content).strip()
+        if hashlib.sha224(raw.encode('utf-8')).hexdigest() == digest:
+          log.debug('Saw previously posted announcement {} with digest {}'.format(announcement['id'], digest))
+          already_posted = True
+          break
+
+  if announcement['voice_id']:
+    filename = announcement['sound']
+    if not filename:
+      filename = 'meow.wav'
+
+  try:
+    if bot.get('dryrun'):
+      if not already_posted:
+        if voice_only:
+          log.info('Dryrun: Not playing announcement {} {} in {}'.format(announcement['id'], filename, channel))
+        else:
+          log.info('Dryrun: Not posting announcement {} to {}: {}'.format(announcement['id'], channel, text))
+    else:
+      announce = random.random() <= announcement['probability'] if not already_posted else 0
+      if announce:
+        if voice_only:
+          yield from wake_up()
+        else:
+          log.info('Posting announcement {} to {}: {}'.format(announcement['id'], channel, text))
+          message = yield from bot.say(channel, text)
+          update['digest'] = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
+      else:
+        if announcement['probability'] < 0:
+          log.info("Announcement {} is paused".format(announcement['id']))
+        else:
+          log.info("Didn't bother with announcement {}".format(announcement['id']))
+      # Set last_spoke even if we chose not to announce.
+      db.update_announcement(client, announcement['id'], **update)
+      if not announce:
+        return
+      if announcement['voice_id']:
+        log.info('Playing announcement {} {} in {}'.format(announcement['id'], filename, channel))
+        yield from play_sound(client.get_channel(str(announcement['voice_id'])), filename)
+  except:
+    logging.exception("announce")
+
+@asyncio.coroutine
+def do_announcements():
+  waittime = 60
+  while True:
+    # Convert to list because we will be sharing the cursor.
+    for announcement in list(db.get_announcements(client)):
+      yield from announce(announcement)
+
+    # Delete old announcements.
+    now = int(time.time())
+    for announcement in db.get_all_announcements(client):
+      if announcement['asap'] == 'true':
+        # Don't delete announcements that were requested ASAP.
+        continue
+      if not announcement['last_spoke']:
+        # Don't delete announcements that haven't played yet.
+        continue
+      if now - announcement['last_spoke'] < max(announcement['interval'], 86400):
+        # Don't delete announcements that aren't old.
+        continue
+      if announcement['interval']:
+        if not announcement['end_date']:
+          # Don't delete ongoing announcements.
+          continue
+        if announcement['end_date'] > now:
+          # Don't delete announcements that still have time to run.
+          continue
+      if bot.get('dryrun'):
+        log.info("Would delete old announcement {}".format(announcement['id']))
+      else:
+        if db.delete_announcement(client, announcement['id']):
+          log.info('Deleted old announcement {}'.format(announcement['id']))
+        else:
+          log.info('Failed to delete old announcement {}'.format(announcement['id']))
+
+    yield from asyncio.sleep(waittime)
+
+@asyncio.coroutine
+def can_manage_feeds(author, channel, command, **args):
+  # Anyone can list feeds.
+  if command == 'list':
+    log.debug('Anyone can list feeds.')
+    return True
+
+  if 'id' in args:
+    feed = db.get_feed(client, args['id'])
+  elif 'create' in args:
+    feed = args['create']
+  else:
+    feed = None
+
+  # Anyone on the server can show details of an announcement.
+  if command == 'show':
+    if feed is not None:
+      server = client.get_server(feed['server_id'])
+      if server is not None:
+        member = server.get_member(author.id)
+        if member in server.members:
+          log.debug('Member {} on server {} can show feed {}.'.format(member.name, server.name, feed['id']))
+          return feed
+
+  if command in ['create', 'edit', 'delete', 'schedule']:
+    if feed is not None:
+      server = client.get_server(feed['server_id'])
+      member = server.get_member(author.id)
+      bot = server.get_member(client.user.id)
+      member_role = bot.highest_role(member.roles)
+      bot_role = bot.highest_role(bot.roles)
+      if member_role.position >= bot_role.position:
+        log.debug('Member {} with role {} on server {} can manage feeds.'.format(member.name, member_role.name, server.name))
+        return feed
+
+  yield from bot.say(channel, 'hiss!')
+  return False
+
+@asyncio.coroutine
+def help_feeds(message, command = None):
+  if command is None:
+    lines = [
+      'Commands to manage news feeds are:',
+      '```',
+      'create',
+      'delete',
+      'edit',
+      'list',
+      'pause',
+      'show',
+      'resume',
+      '```',
+      'Send `feed help COMMAND` for help on a specific command.'
+    ]
+  elif command == 'create':
+    lines = [
+      'Create a news feed.',
+      '```feed create title TITLE in CHANNEL url URL OPTIONS```',
+      'Here are the required arguments and `OPTIONS`:',
+      '',
+      '```title TITLE```',
+      'A description of the news feed, eg GalNet.',
+      '',
+      '```in CHANNEL```',
+      'Post the feed to the specified #channel.',
+      '',
+      '```url URL```',
+      'The URL to retrieve the feed in RSS format.',
+      '',
+      '```date false```',
+      "Use this if the dates returned by the RSS URL aren't valid.  For instance the official GalNet feed always returns the date its cache was updated NOT the date the story was posted.",
+      '',
+      '```summary true```',
+      'Include part of the text from the feed in the post.',
+      '',
+    ]
+  elif command == 'delete':
+    lines = [
+      'Delete the feed with the given ID.',
+      '```feed delete ID```',
+    ]
+  elif command == 'edit':
+    lines = [
+      'Edit a feed.',
+      '```feed edit ID OPTIONS```',
+      'Change one or more `OPTIONS` for the feed with the given ID.',
+      'See the help for `feed create` for details of the OPTIONS you can set.',
+    ]
+  elif command == 'help':
+    lines = ['grr!']
+  elif command == 'list':
+    lines = [
+      'List all feeds, one `ID` per line.  You can use the `ID` in other commands.',
+      "When listing IDs to a public channel I won't show IDs for feeds that are for channels on another server.  Send `announce list` to me in a private message to see them.",
+    ]
+  elif command == 'show':
+    lines = [
+      'Show the feed with the given ID.',
+      '```announcement show ID```',
+      'I will tell you the details in a format which you could copy and paste to create a new feed.',
+    ]
+  elif command in ['pause', 'resume']:
+    lines = [
+      'Schedule the feed with the given ID.',
+      '```',
+      'feed pause ID',
+      'feed resume ID',
+      '```',
+      'Use `pause` and `resume` to put a feed on hold temporarily.'
+    ]
+  else:
+    lines = ['*shrugs*']
+  yield from bot.say_many(message.channel, lines)
+
+@asyncio.coroutine
+def list_feeds(message):
+  result = yield from can_manage_feeds(message.author, message.channel, 'list')
+  if not result:
+    return
+  servers = []
+  if message.channel.is_private:
+    for server in client.servers:
+      if message.author in server.members:
+        servers.append(server)
+  else:
+    servers = [message.channel.server]
+
+  results = []
+  for feed in db.get_all_feeds(client, servers):
+    text = '**{}** url {} in <#{}>'.format(feed['id'], feed['url'], feed['channel_id'])
+    results.append(text)
+
+  if len(results):
+    yield from bot.say_many(message.channel, results)
+  else:
+    yield from bot.say(message.channel, '*shrugs*')
+
+@asyncio.coroutine
+def show_feed(message, id):
+  feed = yield from can_manage_feeds(message.author, message.channel, 'show', id = id)
+  if not feed:
+    return
+  lines = []
+  text = '**feed {}'.format(id)
+  if not bot.parse_boolean(feed['enabled']):
+    text += ' paused'
+  text += '**'
+  lines.append(text)
+
+  text = 'feed create'
+  text += ' title "{}"'.format(feed['description'])
+  text += ' url {}'.format(feed['url'])
+  text += ' in <#{}>'.format(feed['channel_id'])
+  text += ' date {}'.format(feed['date'])
+  text += ' summary {}'.format(feed['summary'])
+  if feed['link_json']:
+    text += "--link_json '{}'".format(feed['link_json'])
+  lines.append(text)
+
+  yield from bot.say(message.channel, '\n'.join(lines))
+
+@asyncio.coroutine
+def parse_feed(message, raw, editing = False):
+  parsed = {}
+  if editing:
+    command = 'edit'
+  else:
+    command = 'create'
+
+  log.debug('feed {} params {}'.format(command, raw))
+  args = shlex.split(raw)
+  log.info(args)
+  ok = False
+  i = 0
+  while i < len(args):
+    arg = args[i].lower()
+    if i > len(args) - 1:
+      break
+    try:
+      param = args[i + 1]
+    except IndexError:
+      param = None
+    log.info('{}: {}={}'.format(i, arg, param))
+    ok = False
+
+    if arg == command:
+      # UUID missing 'id'.
+      if editing and re.match(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', param):
+        parsed['id'] = param
+      else:
+        i -= 1
+      ok = True
+    elif arg == 'id':
+      if editing:
+        parsed['id'] = param
+        ok = True
+      else:
+        break
+    elif arg == 'title':
+      k = 'description'
+      parsed[k] = param
+      ok = True
+    elif arg == 'channel':
+      k = 'channel_id'
+      m = re.match(r'<#(\d+)>', param)
+      if m is None:
+        m = re.match(r'#(\d+)', param)
+      if m is not None:
+        parsed[k] = m.group(1)
+        ok = True
+      else:
+        m = re.match(r'#(.+)', param)
+        if m is None:
+          break
+        # Look for a channel with that name.
+        for channel in client.get_all_channels():
+          if channel.name == m.group(1):
+            parsed[k] = channel.id
+            ok = True
+            break
+    elif arg == 'url':
+      k = 'url'
+      m = re.match(r'https?://', str(param))
+      if m is not None:
+        parsed[k] = param
+        ok = True
+      else:
+        break
+    elif arg in ['date', 'summary']:
+      k = arg
+      if param in ['true', 'false']:
+        parsed[k] = param
+        ok = True
+      else:
+        break
+    else:
+      yield from bot.say(message.channel, 'What is {}?'.format(arg))
+      return
+
+    if ok:
+      i += 2
+    else:
+      break
+
+  if not ok:
+    log.info('Failed to parse feed.  Got: {}'.format(parsed))
+    yield from bot.say(message.channel, '{}?'.format(arg))
+    return None
+
+  if 'channel_id' in parsed:
+    channel = client.get_channel(parsed['channel_id'])
+    if not channel:
+      yield from bot.say(message.channel, 'Invalid channel!')
+      return None
+    server = channel.server
+    parsed['server_id'] = server.id
+
+  if editing:
+    if 'id' not in parsed:
+      yield from bot.say(message.channel, 'Missing ID!')
+      return None
+  else:
+    if 'channel_id' not in parsed:
+      yield from bot.say(message.channel, 'Missing channel!')
+      return None
+    if 'url' not in parsed:
+      yield from bot.say(message.channel, 'Missing URL!')
+      return None
+    if 'description' not in parsed:
+      yield from bot.say(message.channel, 'Missing title!')
+      return None
+
+
+  return parsed
+
+@asyncio.coroutine
+def create_feed(message, raw):
+  # feed create [params]: <text>
+  create = yield from parse_feed(message, raw)
+  if create is None:
+    return
+
+  feed = yield from can_manage_feeds(message.author, message.channel, 'create', create = create)
+  if not feed:
+    return
+
+  id = db.create_feed(client, **create)
+  if id:
+    create['id'] = id
+    log.info('Created feed: {}'.format(create))
+    yield from bot.say(message.channel, id)
+    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+  else:
+    log.info('Failed to create feed: {}'.format(create))
+    yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def edit_feed(message, raw):
+  update = yield from parse_feed(message, raw, True)
+  if update is None:
+    return
+
+  id = update['id']
+  del(update['id'])
+  feed = yield from can_manage_feeds(message.author, message.channel, 'edit', id = id)
+  if not feed:
+    return
+
+  if not len(update.keys()):
+    yield from bot.say(message.channel, '?')
+    return
+
+  if db.update_feed(client, id, **update):
+    log.info('Edited feed {}: {}'.format(id, update))
+    yield from bot.say(message.channel, ', '.join([feed_key(k) for k in update]))
+    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
+  else:
+    log.info('Failed to edit feed {}: {}'.format(id, update))
+    yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def delete_feed(message, id):
+  feed = yield from can_manage_feeds(message.author, message.channel, 'delete', id = id)
+  if not feed:
+    return
+  if bot.get('dryrun'):
+    log.info('Not deleting feed {}'.format(id))
+  else:
+    log.info('Deleting feed {}'.format(id))
+    if db.delete_feed(client, id):
+      yield from bot.say(message.channel, 'purr')
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def schedule_feed(message, id, **args):
+  feed = yield from can_manage_feeds(message.author, message.channel, 'schedule', id = id)
+  if not feed:
+    return
+
+  update = {}
+
+  # Pause feed.
+  if 'pause' in args:
+    if args['pause']:
+      update['enabled'] = 'false'
+    else:
+      update['enabled'] = 'true'
+
+  if bot.get('dryrun'):
+    log.info('Not updating feed {}: {}'.format(id, update))
+  else:
+    log.info('Updating feed {}: {}'.format(id, update))
+    if db.update_feed(client, id, **update):
+      yield from bot.say(message.channel, 'purr')
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+
+@asyncio.coroutine
+def manage_feeds(message, command, raw):
+  log.debug('Command: {}'.format(raw))
+
+  m = re.match(r'(?:news|feed)?\s+(.+)', raw, re.IGNORECASE | re.DOTALL)
+  if m is None:
+    yield from list_feeds(message)
+    return
+  text = m.group(1)
+  args = shlex.split(text)
+  command = args[0].lower()
+  if len(args) == 1:
+    if command == 'list':
+      yield from list_feeds(message)
+      return
+    elif command == 'help':
+      yield from help_feeds(message)
+      return
+    else:
+      yield from bot.say(message.channel, 'yelp!')
+      return
+
+  if command == 'show':
+    yield from show_feed(message, args[1])
+  elif command == 'help':
+    yield from help_feeds(message, args[1])
+  elif command == 'delete':
+    yield from delete_feed(message, args[1])
+  elif command == 'pause':
+    yield from schedule_feed(message, args[1], pause = True)
+  elif command == 'resume':
+    yield from schedule_feed(message, args[1], pause = False)
+  elif command == 'create':
+    yield from create_feed(message, text)
+  elif command == 'edit':
+    yield from edit_feed(message, text)
+
+@asyncio.coroutine
+def maybe_sleep():
+  while True:
+    if bot.status() != discord.Status.idle:
+      now = time.time()
+      if now - bot.get('last_spoke') >= idletime.value:
+        yield from bot.zzz()
+    yield from asyncio.sleep(60)
+
+p = pexpect.spawnu('./edi.py')
+wait_for_prompt(p)
+
+modules = {
+  'admin': {
+    'fn': do_commands,
+    'args': [False],
+    'commands': ['avatar', 'debug', 'delete', 'dryrun', 'idle', 'rss']
+  },
+  'announcements': {
+    'fn': manage_announcements,
+    'args': [],
+    'commands': ['announce', 'announcement']
+  },
+  'feeds': {
+    'fn': manage_feeds,
+    'args': [],
+    'commands': ['news', 'feed']
+  },
+  'any': {
+    'fn': do_commands,
+    'args': [True],
+    'commands': ['bling', 'fuss', 'help', 'id', 'stroke', 'time', 'treat', 'pat']
+  },
+  'edi': {
+    'fn': do_edi,
+    'args': [p],
+    'commands': ['coords', 'close_to', 'distance', 'edts', 'find', 'fuel_usage', 'galmath', 'raikogram']
+  },
+  'fork': {
+    'fn': do_fork,
+    'args': [],
+    'commands': ['gravity', 'material', 'range']
+  }
+}
+
+client = discord.Client()
+bot.client = client
+bot.db = db
+plugins.set_client(client)
+
+@asyncio.coroutine
+def process_module(message, command, raw):
+  for module, params in modules.items():
+    if command in params['commands']:
+      log.info('Recognised command "{}" from {} in "{}" module'.format(command, message.author.name, module))
+      fn = params['fn']
+      args = params['args']
+      yield from fn(message, command, raw, *args)
+      return
+  log.debug('Unrecognised command from {}!'.format(message.author.name))
+  if bot.status() == discord.Status.idle:
+    yield from bot.maybe_say(message.channel, 'zzz', wake = False)
+  else:
+    yield from bot.maybe_say(message.channel, 'meow!')
+
+def channels_to_greet():
+  greetings = get_bot_variable('greetings', [])
+  if not len(greetings):
+    return []
+  channels = []
+  for channel in client.get_all_channels():
+    if channel.is_private:
+      log.debug('Not a channel to greet: {} is private'.format(channel))
+      continue
+    if int(channel.id) not in [greeting['channel'] for greeting in greetings]:
+      log.debug('Not a channel to greet: {} not in {}'.format(channel, [greeting['channel'] for greeting in greetings]))
+      continue
+    log.debug('Channel to greet: {}'.format(channel))
+    channels.append(channel)
+  return channels
+
+def should_greet_member_in(channel, member, role_name = 'Unassigned', minimum = 1):
+  if member not in channel.server.members:
+    log.debug('Not greeting {} not in server memberlist'.format(member))
+    return False
+  permissions = channel.permissions_for(member)
+  if permissions is None:
+    log.debug('Not greeting {} with no permissions'.format(member))
+    return False
+  if not permissions.read_messages:
+    log.debug('Not greeting {} with no read message permission'.format(member))
+    return False
+  if role_name in [role.name for role in member.roles]:
+    log.debug('Not greeting {} with {} role already'.format(member, role_name))
+    return False
+  if len(member.roles) > minimum:
+    log.debug('Not greeting {} with more than {} roles'.format(member, minimum))
+    return False
+  log.info('Greeting {} in {}'.format(member, channel.name))
+  return True
+
+def voice_channel_for_channel(channel):
+  for voice in client.voice_clients:
+    if voice.server != channel.server:
+      continue
+    if not voice.is_connected:
+      continue
+    return voice.channel
+
+@asyncio.coroutine
+def maybe_play_sound(channel, filename, *, probability = 0.05, join = True):
+  if not channel or not filename:
+    if join:
+      log.warning('Missing channel and/or filename for play_sound()')
+    return False
+
+  if random.random() > probability:
+    log.debug("Didn't bother to play {}".format(filename))
+    return False
+
+  while playing.value:
+    yield from asyncio.sleep(1)
+
+  playing.value = True
+  voice = client.voice_client_in(channel.server)
+  if voice is None or voice.channel != channel:
+    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:
+      yield from client.move_to(channel)
+    else:
+      yield from 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))
+      playing.value = False
+      return False
+  try:
+    log.info('Playing {} in channel {}'.format(filename, channel))
+    player = voice.create_ffmpeg_player(filename)
+    player.start()
+    while not player.is_done():
+      yield from asyncio.sleep(1)
+    playing.value = False
+    log.info('Finished playing {} in channel {}'.format(filename, channel))
+  except:
+    log.error("Error creating ffpmeg player for {}".format(filename))
+    playing.value = False
+    return False
+
+@asyncio.coroutine
+def play_sound(channel, filename, *, join = True):
+  result = yield from maybe_play_sound(channel, filename, probability = 1.0, join = join)
+  return result
+
+@asyncio.coroutine
+def play_greeting(greeting):
+  if 'voice' not in greeting:
+    return
+  yield from play_sound(client.get_channel(str(greeting['voice'])), greeting['sound'])
+
+@client.event
+@asyncio.coroutine
+def on_ready():
+  state = db.get_state(client)
+  if state is not None:
+    if state['idle']:
+      log.info('Restoring idle status.')
+      yield from bot.set_idle(True)
+      if state['avatar'] != db.IDLE_AVATAR:
+        log.info('Restoring idle avatar.')
+        yield from set_avatar(db.IDLE_AVATAR)
+    elif state['avatar'] != db.ONLINE_AVATAR:
+      log.info('Restoring online avatar.')
+      yield from 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))
+  log.info('Admins are: {}'.format(admins))
+  channels = channels_to_greet()
+  log.info('Greeting in {}'.format([channel.name for channel in channels]))
+  for channel in channels:
+    log.debug('Greeting in {}'.format(channel.name))
+    sound = True
+    for member in channel.server.members:
+      result = should_greet_member_in(channel, member)
+      if not result:
+        continue
+      yield from greet(channel, member, sound)
+      sound = False
+  for member in client.get_all_members():
+    log.debug('Maybe unassigning {}'.format(member.name))
+    yield from set_unassigned(member)
+  if not threads_ready.value:
+    for plugin in plugins.all():
+      asyncio.async(plugin.on_ready())
+    asyncio.async(do_announcements())
+    asyncio.async(maybe_sleep())
+    threads_ready.value = True
+
+def mentioned_in(message, explicit = True):
+  if message.channel.is_private:
+    return True
+  if not client.user.mentioned_in(message):
+    return False
+  if explicit:
+    for member in message.mentions:
+      if member.id == client.user.id:
+        return True
+    return False
+  return True
+
+@client.event
+@asyncio.coroutine
+def on_message(message):
+  if message.author.id == client.user.id:
+    return
+  log.debug('Got message from {}: {}'.format(message.author, message.content))
+  if not mentioned_in(message):
+    log.debug('Message is not for me!')
+    return
+  raw = re.sub('<@{}>'.format(client.user.id), '', message.content).strip()
+  m = re.match(r'^\s*(\S+)', raw)
+  if m is None:
+    return
+  command = m.group(1)
+  yield from process_module(message, command, raw)
+
+@client.event
+@asyncio.coroutine
+def on_member_join(member):
+  channels = channels_to_greet()
+  for channel in channels:
+    result = should_greet_member_in(channel, member)
+    if result:
+      yield from greet(channel, member)
+  yield from set_unassigned(member)
+
+client.run(token)
diff --git a/bot.py b/bot.py
old mode 100755 (executable)
new mode 100644 (file)
index d05df35..95c2ee3
--- a/bot.py
+++ b/bot.py
-#!/usr/bin/env python
-
 import asyncio
-import bs4
-import datetime
 import discord
-import feedparser
-import hashlib
-import io
-import json
-import logging
-import math
 import multiprocessing
 import os
-import pexpect
-import pytz
 import random
 import re
-import shlex
-import sys
 import time
-from PIL import Image
-from db import DBConnection
-from enum import Enum
-
-class Identify(Enum):
-  member = 1
-  role = 2
-  channel = 4
-
-  def string(instance):
-    if type(instance) == discord.member.Member:
-      return 'Member'
-    if type(instance) == discord.member.User:
-      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)
-      return 'Channel'
-
-dryrun = os.getenv('DRYRUN') is not None
-debug = os.getenv('DEBUG') is not None
-
-if sys.version_info >= (3, 0):
-  import urllib.request
-else:
-  import urllib2
-
-db = DBConnection()
 
-token_file = 'TOKEN'
-if len(sys.argv) > 1:
-  token_file = sys.argv[1]
-
-admins = []
-admin_role = 'Cat'
-fd = open('ADMINS', 'r')
-for line in fd.readlines():
-  admin = line.strip()
-  admins.append(admin)
-fd.close()
-
-fd = open(token_file, 'r')
-token = fd.readline().strip()
-fd.close()
-logging.basicConfig(level = logging.DEBUG if debug else logging.WARN, format = '%(asctime)s %(name)s: %(levelname)s: %(funcName)s@%(module)s:%(lineno)d: %(message)s')
-discordlog = logging.getLogger('discord')
-log = logging.getLogger('catbot')
-log.setLevel(logging.DEBUG if debug else logging.INFO)
-
-idletime = multiprocessing.Value('i', 900)
-rsstime = multiprocessing.Value('i', 600)
-playing = multiprocessing.Value('b', False)
-threads_ready = multiprocessing.Value('b', False)
-message_limit = multiprocessing.Value('i', 100)
-
-bots = {
-  # catbot
-  '225207358450696192': {
-    'greetings': [
-      { 'channel': 222685317885329408, 'message': 'Welcome to Delta Squadron, take a look at #delta_rules and make yourself at home in the public lobby. If interested in joining please PM a member of the High Command or Council for assistance', 'voice': 222103336483028993, 'sound': 'meow.wav' }
-    ]
-  },
-  # catbot beta
-  '229879865686360064': {
-    'greetings': [
-      { 'channel': 225217541922881538, 'message': 'Welcome to Catbot', 'voice': 231296897938227201, 'sound': 'meow.wav' }
-    ]
-  }
+variables = {
+  'dryrun': multiprocessing.Value('b', os.getenv('DRYRUN') is not None),
+  'debug': multiprocessing.Value('b', os.getenv('DEBUG') is not None),
+  'last_spoke': multiprocessing.Value('f', time.time()),
+  'message_limit': multiprocessing.Value('i', 100),
+  'playing': multiprocessing.Value('b', False)
 }
 
-def get_bot_variable(k, v = None):
-  bot = bots.get(str(client.user.id), [])
-  if k in bot:
-    return bot.get(k, v)
-  return v
+def get(key):
+  if key in variables:
+    return variables[key].value
+  else:
+    log.error('No such variable {}'.format(key))
+    return None
+
+def set(key, value):
+  if key in variables:
+    variables[key].value = value
+  else:
+    log.error('No such variable {}'.format(key))
 
 def parse_boolean(string):
   if string.lower() == 'true':
@@ -174,13 +104,9 @@ def open_url(url):
       log.error('Invalid URL {}'.format(url))
       return None
 
-def wait_for_prompt(p):
-  p.expect('EDI> ')
-
 # Get my idle status.
 def status():
   for server in client.servers:
-    log.debug('status {} now {} last_spoke {} diff {} cutoff {}'.format(server.me.status, int(time.time()), int(last_spoke.value), int(time.time() - last_spoke.value), idletime.value))
     return server.me.status
   return None
 
@@ -210,12 +136,12 @@ def set_idle(idle):
 
 @asyncio.coroutine
 def wake_up(force = False):
-  last_spoke.value = time.time()
+  set('last_spoke', time.time())
   if force or status() != discord.Status.online:
     log.info('Waking up...')
     yield from set_avatar(db.ONLINE_AVATAR)
     yield from set_idle(False)
-    db.set_state(client, avatar = db.ONLINE_AVATAR, idle = False, last_spoke = last_spoke.value)
+    db.set_state(client, avatar = db.ONLINE_AVATAR, idle = False, last_spoke = get('last_spoke'))
 
 @asyncio.coroutine
 def zzz():
@@ -262,525 +188,6 @@ def highest_role(roles):
       highest = role
   return highest
 
-@asyncio.coroutine
-def say_expect(message, stdout):
-  if stdout:
-    log.debug(stdout)
-    result = yield from say(message.channel, '{}```{}```'.format('{} '.format(message.author.mention) if not message.channel.is_private else '', stdout))
-  else:
-    result = yield from say(message.channel, 'Sorry!')
-  return result
-
-@asyncio.coroutine
-def not_admin(message):
-  log.warning('Not admin: {} id {}'.format(message.author, message.author.id))
-  yield from maybe_say(message.channel, 'hiss!')
-
-@asyncio.coroutine
-def is_admin(server, member):
-  if server is None:
-    return member.id in admins
-  for role in server.roles:
-    if role.name != admin_role:
-      continue
-    if role in member.roles:
-      return True
-  return False
-
-def create_avatar(url, bgfile, fgfile):
-  # Get Delta images.
-  bg = Image.open(bgfile)
-  fg = Image.open(fgfile)
-  if not bg or not fg:
-    log.warning("Can't open avatar foreground and/or background image!")
-    return None
-  cutoff = float(bg.width) / 1.5
-
-  # Get provided image.
-  fd = open_url(url)
-  if fd is None:
-    log.debug('Failed to open URL {} for avatar creation'.format(url))
-    return None
-  try:
-    image = Image.open(fd)
-  except:
-    log.warning('Failed to get image from {}'.format(url))
-    return None
-  fd.close()
-  if image is None:
-    return None
-
-  # Crop square.
-  box = None
-  if image.height > image.width:
-    top = math.floor((image.height - image.width) / 2)
-    box = 0, top, image.width - 1, top + image.width - 1
-  elif image.height < image.width:
-    left = math.floor((image.width - image.height) / 2)
-    box = left, 0, left + image.height - 1, image.height - 1
-  if box is not None:
-    cropped = image.crop(box)
-    image = cropped.copy()
-
-  # Scale leaving space for background.
-  if image.height > cutoff:
-    image.thumbnail((cutoff, cutoff))
-  elif image.height < cutoff:
-    width = math.floor(image.width * (float(fg.width) / cutoff))
-    height = math.floor(image.height * (float(fg.height) / cutoff))
-    bg.thumbnail((width, height))
-    fg.thumbnail((width, height))
-
-  # Pad with blank pixels.
-  padded = Image.new(bg.mode, fg.size)
-  left = math.floor((padded.width - image.width) / 2)
-  top = math.floor((padded.height - image.height) / 2)
-  padded.paste(image, (left, top, left + image.width, top + image.height))
-
-  # Merge them.
-  result = Image.alpha_composite(Image.alpha_composite(bg, padded), fg)
-  b = io.BytesIO()
-  result.save(b, 'PNG')
-  return b.getvalue()
-
-def bucky_avatar(url):
-  return create_avatar(url, 'buckybg.png', 'buckyfg.png')
-
-def delta_avatar(url):
-  return create_avatar(url, 'DELTA-BG.png', 'DELTA-FURRYTEMPLATE.png')
-
-@asyncio.coroutine
-def identify(destination, target):
-  do = Identify.member.value | Identify.role.value | Identify.channel.value
-  channel_id = None
-  role_id = None
-  member_id = None
-
-  if target:
-    m = re.match(r'<(@&?|#)(\d+)>', target)
-    if m is not None:
-      target = None
-      prefix = m.group(1)
-      id = m.group(2)
-      if prefix == '@':
-        member_id = str(id)
-        do = Identify.member.value
-      elif prefix == '@&':
-        role_id = str(id)
-        do = Identify.role.value
-      elif prefix == '#':
-        channel_id = str(id)
-        do = Identify.channel.value
-    else:
-      m = re.match(r'--(member|role|channel)s?', target)
-      if m is not None:
-        target = None
-        suffix = m.group(1)
-        if suffix == 'member':
-          do = Identify.member.value
-        elif suffix == 'role':
-          do = Identify.role.value
-        elif suffix == 'channel':
-          do = Identify.channel.value
-
-  results = []
-  if do & Identify.member.value:
-    if member_id:
-      for server in client.servers:
-        member = server.get_member(member_id)
-        if member is not None:
-          results.append(member)
-          break
-    else:
-      for member in client.get_all_members():
-        if target:
-          parts = target.lower().split('#')
-          if member.name.lower() != parts[0]:
-            continue
-          if len(parts) > 1 and member.discriminator != parts[1]:
-            continue
-        results.append(member)
-  if do & Identify.role.value:
-    if role_id:
-      for server in client.servers:
-        for role in server.roles:
-          if role.id == role_id:
-            results.append(role)
-            break
-    else:
-      for server in client.servers:
-        for role in server.roles:
-          if target and role.name.lower() != target.lower():
-            continue
-          results.append(role)
-  if do & Identify.channel.value:
-    if channel_id:
-      channel = client.get_channel(channel_id)
-      if channel is not None:
-        results.append(channel)
-    else:
-      for channel in client.get_all_channels():
-        if target and channel.name.lower() != target.lower():
-          continue
-        results.append(channel)
-
-  yield from say_many(destination, ['{} {} {}'.format(Identify.string(result), result.id, result.name) for result in set(results)])
-
-@asyncio.coroutine
-def show_help(message, *args):
-  if len(args):
-    command = args[0]
-  else:
-    command = None
-
-  if command is None:
-    lines = [
-      '<@{}>, the cat-like robot from the 34th century of the future, recognises these commands (and more):'.format(client.user.id),
-      '```',
-      'announce',
-      'coords',
-      'bling',
-      'close_to',
-      'distance',
-      'edts',
-      'find',
-      'fuel_usage',
-      'galmath',
-      'id',
-      'raikogram',
-      'time',
-      '```',
-      'Send `help COMMAND` for help on a specific command.'
-    ]
-  elif command == 'announce' or command == 'announcement':
-    if len(args) > 1:
-      yield from help_announcements(message, args[1])
-    else:
-      yield from help_announcements(message)
-    return
-  elif command == 'feed' or command == 'news':
-    if len(args) > 1:
-      yield from help_feeds(message, args[1])
-    else:
-      yield from help_feeds(message)
-    return
-  elif command == 'bling':
-    lines = [
-      'Convert an image into a Delta Squadron or Buckyball Racing Club avatar suitable for use on Discord.',
-      '```',
-      'bling --bucky http://example.com/image.png',
-      'bling http://example.com/image.png',
-      '```',
-      'Blings the image at the given URL.',
-      "Note that I can't bling certain URLs, and I will yelp at you if you ask me to.  In that case you can download the image yourself and attach it to a message to me.",
-      '',
-      '```',
-      'bling --bucky @user',
-      'bling @user',
-      '```',
-      'Blings the existing avatar of the tagged user.',
-      'Needless to say, only users who have set an avatar can have it blinged.',
-      '',
-      '```',
-      'bling --bucky',
-      'bling',
-      '```',
-      'With no arguments but with an image attached to the message, I will bling the image.  With no arguments and no attachment I will bling *your* avatar.'
-    ]
-  elif command == 'help':
-    lines = ['grr!']
-  elif command == 'id':
-    lines = [
-      'Print the Discord IDs of member, roles or channels known to me.',
-      '```',
-      'id MEMBER',
-      'id ROLE',
-      'id CHANNEL',
-      '```',
-      'Prints the ID of the member, role or channel with the given name.',
-      "I can only identify someone or something if I share a server.",
-      'Useful for `announce create` to specify a `voice` channel, which must be done by ID.',
-      '',
-      '```',
-      'id --members',
-      'id --roles',
-      'id --channels',
-      'id',
-      '```',
-      'Identify all MEMBERs, ROLEs or CHANNELs known to me.',
-      "If you don't give any arguments I'll identify everyone and everything I know about.",
-      'Note that if I share more than one server with a member, that member will be listed twice with two IDs!',
-      "Also note that because of the Discord message length limit I'll probably have to split my reply into multiple posts."
-    ]
-  elif command == 'time':
-    lines = [
-      'Print the time in UTC.',
-      '```time```',
-      'Useful for `announce create` or to check in-game time.'
-    ]
-  elif command == 'treat':
-    lines = [
-      "You can give me a treat if you like.  I may (or may not) show gratitude.",
-      'Probably not.'
-    ]
-  elif command in ['coords', 'close_to', 'distance', 'edts', 'find', 'fuel_usage', 'galmath', 'raikogram']:
-    params = modules['edi']
-    fn = params['fn']
-    args = params['args']
-    yield from fn(message, 'help', 'help {}'.format(command), *args)
-    return
-  else:
-    lines = ['*shrugs*']
-  yield from say_many(message.channel, lines)
-
-@asyncio.coroutine
-def do_commands(message, command, raw, non_admin):
-  if not non_admin:
-    result = yield from is_admin(message.server, message.author)
-    if not result:
-      yield from not_admin(message)
-      return
-
-  log.debug('Command: {}'.format(raw))
-
-  if command == 'avatar':
-    # avatar
-    url = None
-    m = re.match(r'\bavatar\s+(\S+)', raw, re.IGNORECASE)
-    if m is not None:
-      url = m.group(1)
-    elif len(message.attachments):
-      url = message.attachments[0]['url']
-    if url is None:
-      log.debug('No URL for avatar')
-      yield from say(message.channel, 'zzz', wake = False)
-      return
-    result = yield from set_avatar(url)
-    if not result:
-      yield from say(message.channel, 'yelp!')
-      return
-
-  elif command == 'bling':
-    # bling
-    url = None
-
-    args = shlex.split(raw)
-    fn = delta_avatar
-    for i in range(1, len(args)):
-      arg = args[i].lower()
-      if arg == '--delta':
-        continue
-      elif arg == '--bucky':
-        fn = bucky_avatar
-      else:
-        url = args[i]
-
-    filename = message.author.name
-    mentions = [message.author.mention] if not message.channel.is_private else None
-    if url is not None:
-      m = re.match(r'<@(!?\d+)>', url)
-      if m is not None:
-        other = None
-        id = str(m.group(1))
-        for member in client.get_all_members():
-          if member.id == id:
-            other = member
-        if other is None:
-          log.warning("Can't see requested user {}".format(url))
-          url = None
-        else:
-          if mentions is not None:
-            filename = other.name
-            mentions.append(other.mention)
-          url = other.avatar_url
-    elif len(message.attachments):
-      url = message.attachments[0]['url']
-    else:
-      log.info('Using avatar URL {}'.format(url))
-      url = message.author.avatar_url
-    if url is None:
-      log.debug('No URL for avatar to Deltaify')
-      yield from say(message.channel, 'zzz', wake = False)
-      return
-    yield from client.send_typing(message.channel)
-    data = fn(url)
-    if data is None:
-      yield from say(message.channel, 'yelp!')
-      return
-    yield from client.send_file(message.channel, data, filename = filename + '.png', content = ' '.join(mentions) if mentions is not None else 'purr')
-    return
-
-  elif command == 'debug':
-    # debug
-    m = re.match(r'\bdebug\s+(o(?:ff|n))\b', raw, re.IGNORECASE)
-    if m is None:
-      yield from say(message.channel, 'Sorry!')
-      return
-
-    arg = m.group(1)
-    if arg == 'on':
-      discordlog.setLevel(logging.DEBUG)
-      log.setLevel(logging.DEBUG)
-      db.log_level(logging.DEBUG)
-    elif arg == 'off':
-      discordlog.setLevel(logging.WARN)
-      log.setLevel(logging.INFO)
-      db.log_level(logging.WARN)
-
-  elif command == 'delete':
-    # delete
-    if message.channel.is_private:
-      yield from say(message.channel, 'hiss', wake = False)
-      return
-
-    args = {}
-    limit = message_limit.value
-    m = re.match(r'\bdelete\s+(all|\d+)\b', raw, re.IGNORECASE)
-    if m is not None:
-      if m.group(1) != 'all':
-        limit = int(m.group(1))
-    yield from client.purge_from(message.channel, *args, limit = limit)
-    return
-
-  elif command == 'dryrun':
-    # dryrun
-    m = re.match(r'\bdryrun\s+(o(?:ff|n))\b', raw, re.IGNORECASE)
-    if m is None:
-      yield from say(message.channel, 'Sorry!')
-      return
-
-    arg = m.group(1)
-    if arg == 'on':
-      log.info('DRYRUN mode')
-      dryrun = True
-    elif arg == 'off':
-      log.info('Live mode')
-      dryrun = False
-
-  elif command == 'id':
-    # id
-    m = re.match(r'\bid\s+(.*)', raw, re.IGNORECASE)
-    if m is not None:
-      target = m.group(1)
-    else:
-      target = None
-    yield from identify(message.channel, target)
-
-  elif command == 'help':
-    # help
-    m = re.match(r'\bhelp\s+(.+)', raw, re.IGNORECASE)
-    if m is not None:
-      args = shlex.split(m.group(1))
-      yield from show_help(message, *args)
-    else:
-      yield from show_help(message)
-
-  elif command == 'idle':
-    # idle
-    m = re.match(r'\bidle\s+(\d+)\b', raw, re.IGNORECASE)
-    if m is None:
-      yield from say(message.channel, 'Sorry!')
-      return
-
-    idletime.value = int(m.group(1))
-
-  elif command == 'rss':
-    # rss
-    m = re.match(r'\brss\s+(\d+)\b', raw, re.IGNORECASE)
-    if m is None:
-      yield from say(message.channel, 'Sorry!')
-      return
-
-    rsstime.value = int(m.group(1))
-
-  elif command == 'time':
-    # time
-    yield from say(message.channel, iso8601(int(time.time())))
-    yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-    return
-
-  elif command in ['treat', 'stroke', 'fuss', 'pat']:
-    # treat
-    if command == 'treat':
-      responses = ['om nom nom', '^-^', 'meow!', 'lick']
-    else:
-      responses = ['nuzzle', '^-^', 'meow!', 'mrrp']
-    for response in responses:
-      said = yield from maybe_say(message.channel, response)
-      if said:
-        yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-        return
-
-  yield from maybe_say(message.channel, 'purr', wake = False)
-  yield from maybe_play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-
-@asyncio.coroutine
-def do_edi(message, command, raw, p):
-  if command == 'close_to' and not '-m' in raw:
-    raw = 'close_to'
-  if command == 'distance':
-    raw = raw.replace(',', '')
-  log.info(raw)
-  yield from client.send_typing(message.channel)
-  p.sendline(raw)
-  wait_for_prompt(p)
-  yield from say_expect(message, p.before)
-
-@asyncio.coroutine
-def do_fork(message, command, raw):
-  yield from client.send_typing(message.channel)
-  if re.search(r'[^A-Za-z0-9-_."\'\s]', raw):
-    yield from say(message.channel, 'hiss')
-    return
-  p = pexpect.spawnu('./{}'.format(raw))
-  p.expect(pexpect.EOF)
-  yield from say_expect(message, p.before)
-
-@asyncio.coroutine
-def set_role(member, role_name = 'Unassigned', minimum = 1):
-  role = None
-  for server_role in member.server.roles:
-    if server_role.name != role_name:
-      continue
-    role = server_role
-    break
-  if role is None:
-    log.warning("Can't find {} role to assign to {}".format(role_name, member.name))
-    return
-
-  if minimum is not None and len(member.roles) > minimum:
-    return
-
-  if dryrun:
-    log.info('Not assigning {} role to {}'.format(role.name, member.name))
-    return
-  log.info('Adding {} role to {}'.format(role.name, member.name))
-  try:
-    yield from client.add_roles(member, role)
-  except discord.errors.Forbidden:
-    log.warning('Forbidden to add {} role to {}'.format(role.name, member.name))
-
-@asyncio.coroutine
-def set_unassigned(member):
-  result = yield from is_admin(None, member)
-  if result:
-    log.debug('{} is an admin so not setting role {}'.format(member.name, 'Unassigned'))
-    return
-  yield from set_role(member, 'Unassigned')
-
-@asyncio.coroutine
-def greet(channel, member, sound = True):
-  for greeting in get_bot_variable('greetings', []):
-    if greeting['channel'] != int(channel.id):
-      continue
-    message = ' '.join([member.mention, greeting['message']])
-    if dryrun:
-      log.info('Not sending greeting {}'.format(message))
-    else:
-      yield from say(channel, message)
-      if sound:
-        yield from play_greeting(greeting)
-
 # Discord API 0.13 has is_superset() to do this.
 def equal_permission(x, y, *, strict = True):
   if x == y:
@@ -854,1551 +261,3 @@ def overwrite_permissions(channel, whom, overwrite, *, strict = True):
   log.info('Setting permissions for {} in {} to {}'.format(whom.name, channel.name, permissions.__dict__))
   yield from client.edit_channel_permissions(channel, whom, overwrite)
 
-@asyncio.coroutine
-def set_rss_permissions(channel_id):
-  channel = client.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 = highest_role(channel.server.me.roles)
-  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
-    yield from 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
-  yield from overwrite_permissions(channel, channel.server.default_role, overwrite)
-
-@asyncio.coroutine
-def do_rss(feeds):
-  channel_id = str(feeds[0]['channel_id'])
-  channel = client.get_channel(channel_id)
-  if not channel:
-    log.warning("Can't get channel {} for feed.".format(channel_id))
-    return
-
-  all_entries = []
-  now = time.time()
-  for feed in feeds:
-    # Date can be trusted.
-    date = parse_boolean(feed['date']) if 'date' in feed else True
-    # Include summary text.
-    summary = parse_boolean(feed['summary']) if 'summary' in feed else False
-    url = feed['url']
-    d = feedparser.parse(url)
-    for entry in d.entries:
-      if date:
-        order = time.mktime(entry['published_parsed'])
-      else:
-        order = now
-        now += 0.001
-      all_entries.append({ 'order': order, 'date': date, 'summary': summary, 'url': feed['url'], 'link_json': feed['link_json'], 'entry': entry })
-  all_entries.sort(key = lambda entry: entry['order'])
-
-  limit = len(all_entries)
-  if not limit:
-    limit = message_limit.value
-
-  digests = []
-  logs = yield from client.logs_from(channel, limit = limit)
-  for message in logs:
-    digest = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
-    log.debug('Saw previously posted RSS with digest {}'.format(digest))
-    if digest not in digests:
-      digests.append(digest)
-
-  for feed in all_entries:
-    entry = feed['entry']
-    formatted = '*{}*\n'.format(entry['published']) if feed['date'] else ''
-    link = None
-    if 'links' in entry:
-      link = entry['links'][0]['href']
-    elif 'link_json' in feed:
-      try:
-        l = json.loads(feed['link_json'])
-        args = [entry[k] for k in l[1:]]
-        link = l[0].format(*args)
-      except ValueError:
-        log.error('Invalid link_json in feed {}'.format(feed['url']))
-      except:
-        logging.exception('do_rss:link_json')
-    if not link:
-      log.warning('No link for entry {}'.format(entry['title']))
-      continue
-    formatted += '**{}**\n{}'.format(entry['title'], link)
-    if feed['summary']:
-      formatted += '\n{}'.format(''.join(bs4.BeautifulSoup(entry['summary'], 'html.parser').findAll(text = True)))
-    formatted = formatted.strip()
-    digest = hashlib.sha224(formatted.encode('utf-8')).hexdigest()
-    if digest in digests:
-      log.debug("Already posted RSS with digest {}".format(digest))
-      continue
-    try:
-      if dryrun:
-        log.info('Dryrun: Not posting to {}: {}'.format(channel, formatted))
-      else:
-        log.info('Posting to {}: {}'.format(channel, formatted))
-        message = yield from say(channel, formatted)
-        digest = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
-        digests.append(digest)
-        log.debug('Posted RSS with digest {}'.format(digest))
-    except:
-      logging.exception("do_rss")
-
-@asyncio.coroutine
-def do_feeds():
-  while True:
-    feeds = {}
-    for feed in list(db.get_feeds(client)):
-      if not parse_boolean(feed['enabled']):
-        continue
-      feeds.setdefault(feed['channel_id'], [])
-      feeds[feed['channel_id']].append(feed)
-    for channel_id in feeds.keys():
-      yield from set_rss_permissions(channel_id)
-      yield from do_rss(feeds[channel_id])
-    yield from asyncio.sleep(rsstime.value)
-
-@asyncio.coroutine
-def can_manage_announcements(author, channel, command, **args):
-  # Anyone can list announcements.
-  if command == 'list':
-    log.debug('Anyone can list announcements.')
-    return True
-
-  if 'id' in args:
-    announcement = db.get_announcement(client, args['id'])
-  elif 'create' in args:
-    announcement = args['create']
-  else:
-    announcement = None
-
-  # Anyone on the server can show details of an announcement.
-  if command == 'show':
-    if announcement is not None:
-      if announcement['member_id'] == author.id:
-        log.debug('Anyone can show own announcements.')
-        return announcement
-      if announcement['channel_id'] != 'private':
-        server = client.get_server(announcement['server_id'])
-        if server is not None:
-          member = server.get_member(author.id)
-          if member in server.members:
-            log.debug('Member {} on server {} can show announcement {}.'.format(member.name, server.name, announcement['id']))
-            return announcement
-
-  if command in ['edit', 'delete', 'schedule']:
-    if announcement is not None:
-      if announcement['member_id'] == author.id:
-        log.debug('Anyone can manage own announcements.')
-        return announcement
-      if announcement['channel_id'] != 'private':
-        server = client.get_server(announcement['server_id'])
-        announcer = server.get_member(announcement['member_id'])
-        announcer_role = highest_role(announcer.roles)
-        member = server.get_member(author.id)
-        member_role = highest_role(member.roles)
-        if member_role.position > announcer_role.position:
-          log.debug('Member {} on server {} can edit announcements from lower role.'.format(member.name, server.name))
-          return announcement
-        elif member_role.position == announcer_role.position:
-          if member_role.position == highest_role(server.roles).position:
-            log.debug('Member {} with role {} on server {} can edit announcements.'.format(member.name, member_role.name, server.name))
-            return announcement
-
-  if command == 'create':
-    if announcement['channel_id'] == 'private':
-      log.debug('Anyone can schedule a private announcement.')
-      return announcement
-    server = client.get_server(announcement['server_id'])
-    member = server.get_member(announcement['member_id'])
-    bot = server.get_member(client.user.id)
-    member_role = highest_role(member.roles)
-    bot_role = highest_role(bot.roles)
-    if member_role.position > bot_role.position:
-      log.debug('Member {} with role {} on server {} can create announcements.'.format(member.name, member_role.name, server.name))
-      return announcement
-
-  yield from say(channel, 'hiss!')
-  return False
-
-@asyncio.coroutine
-def help_announcements(message, command = None):
-  if command is None:
-    lines = [
-      'Commands to manage announcements are:',
-      '```',
-      'asap',
-      'create',
-      'delete',
-      'edit',
-      'list',
-      'pause',
-      'show',
-      'resume',
-      '```',
-      'Send `announce help COMMAND` for help on a specific command.'
-    ]
-  elif command == 'create':
-    lines = [
-      'Create a new announcement.',
-      '```announce create OPTIONS: MESSAGE```',
-      'Use the `OPTIONS` to define when and where to send the announcement.  The `MESSAGE` can be omitted if you just want me to play a sound.',
-      'You need to include the **:** after the OPTIONS.  Anything you include after it will be part of the MESSAGE!',
-      '',
-      'Here are the OPTIONS you can use:',
-      '',
-      '```every INTERVAL```',
-      'Specify the interval at which the announcement will be sent.  You can include **d**ays, **h**ours, **m**inutes or **s**econds.  `every 6h` means every six hours.  `every 10m30s` means every ten minutes and thirty seconds.',
-      '',
-      '```from DATE```',
-      "Don't start giving the announcement until at least this date.  The date must be specified as YYYY-MM-DDThh:mm:ss, eg {}, and is in **UTC**.  Send me the `time` command and I'll tell you the current time in UTC.".format(iso8601(int(time.time()))),
-      '',
-      '```to DATE```',
-      'Stop giving the announcement after this date.  See the notes on `from` for how to specify the date.',
-      '',
-      '```tell MENTION```',
-      'Mention `@user`, `@role` or `#channel` in the announcement.',
-      '',
-      '```in CHANNEL```',
-      'Send the announcement to the specified #channel.',
-      '',
-      '```voice CHANNEL```',
-      'Play a sound to the specified voice #CHANNEL.',
-      '',
-      '```sound FILE```',
-      "File to play.  I won't play sounds in private messages!",
-      '',
-      'I also accept some shortcuts:',
-      '',
-      "`tell me` means to mention you in the announcement.  If you don't specify a channel with `in` the announcement will be sent in a private message.",
-      '',
-      "`in here` means to send the message to the channel in which you sent `announce create`.  If you send it in a private message the announcement will also be private.",
-      '',
-      '`once` is equivalent to `interval 0` and means to give the announcement just once.',
-      '',
-      '`at DATE` is equivalent to `from DATE to DATE once` and means to give the announcement just once at the specified time.',
-      '',
-      'Example: `announce create every 12h in #channel: This message will be sent twice a day.`'
-    ]
-  elif command == 'delete':
-    lines = [
-      'Delete the announcement with the given ID.',
-      '```announcement delete ID```',
-      'You can only delete your own announcements or public announcements created by someone in a lower role.'
-    ]
-  elif command == 'edit':
-    lines = [
-      'Edit an announcement.',
-      '```announcement edit ID OPTIONS: MESSAGE```',
-      'Change one or more `OPTIONS` or the `MESSAGE` for the announcement with the given ID.',
-      'See the help for `announce create` for details of the OPTIONS you can set.',
-      'Some options can be set to *none* to delete that option.',
-      'For instance `to none` means that the announcement will no longer have an end date.'
-    ]
-  elif command == 'help':
-    lines = ['grr!']
-  elif command == 'list':
-    lines = [
-      'List all announcements, one `ID` per line.  You can use the `ID` in other commands.',
-      "When listing IDs to a public channel I won't show IDs for announcements that are for channels on another server.  Send `announce list` to me in a private message to see them.",
-      "I'll never show the IDs of another user's announcements, even in private."
-    ]
-  elif command == 'show':
-    lines = [
-      'Show the announcement with the given ID.',
-      '```announcement show ID```',
-      'I will tell you the details in a format which you could copy and paste to create a new announcement.',
-      "I'll never show details of another user's private announcements."
-    ]
-  elif command in ['asap', 'pause', 'resume']:
-    lines = [
-      'Schedule the announcement with the given ID.',
-      '```',
-      'announcement asap ID',
-      'announcement pause ID',
-      'announcement resume ID',
-      '```',
-      'Use `asap` to give the announcement as soon as possible regardless of scheduling.',
-      'Use `pause` and `resume` to put an announcement on hold temporarily.'
-    ]
-  else:
-    lines = ['*shrugs*']
-  yield from say_many(message.channel, lines)
-
-@asyncio.coroutine
-def list_announcements(message):
-  result = yield from can_manage_announcements(message.author, message.channel, 'list')
-  if not result:
-    return
-  servers = []
-  if message.channel.is_private:
-    servers.append('private')
-    for server in client.servers:
-      if message.author in server.members:
-        servers.append(server)
-  else:
-    servers = [message.channel.server]
-
-  results = []
-  for announcement in db.get_all_announcements(client, servers):
-    if announcement['channel_id'] == 'private' and announcement['member_id'] != message.author.id:
-      continue
-    text = '**{}**'.format(announcement['id'])
-    if announcement['channel_id'] == 'private':
-      text += ' in private'
-    else:
-      server = client.get_server(announcement['server_id'])
-      member = server.get_member(announcement['member_id'])
-      text += ' by {}'.format(member.name)
-    if announcement['channel_id']:
-      if announcement['channel_id'] != 'private':
-        text += ' in <#{}>'.format(announcement['channel_id'])
-    if announcement['voice_id']:
-      if announcement['sound']:
-        text += ' sound {}'.format(announcement['sound'])
-      if announcement['voice_id'] != announcement['channel_id']:
-        text += ' in <#{}>'.format(announcement['voice_id'])
-    if announcement['message']:
-      short = announcement['message'][:100]
-      if short != announcement['message']:
-        short += '...'
-      text += ': {}'.format(short.replace('\n', ' '))
-    results.append(text)
-
-  if len(results):
-    yield from say_many(message.channel, results)
-  else:
-    yield from say(message.channel, '*shrugs*')
-
-def iso8601(timestamp):
-  return datetime.datetime.utcfromtimestamp(timestamp).isoformat()[:19] + 'Z'
-
-@asyncio.coroutine
-def show_announcement(message, id):
-  announcement = yield from can_manage_announcements(message.author, message.channel, 'show', id = id)
-  if not announcement:
-    return
-  lines = []
-
-  if announcement['channel_id'] == 'private':
-    for server in client.servers:
-      try:
-        member = server.get_member(announcement['member_id'])
-      except:
-        pass
-  else:
-    server = client.get_server(announcement['server_id'])
-    member = server.get_member(announcement['member_id'])
-  text = '**{}announcement {} by {}'.format('private ' if announcement['channel_id'] == 'private' else '', id, member.name)
-  now = int(time.time())
-  if announcement['last_spoke']:
-    offset = announcement['last_spoke']
-    text += ' last given at {}'.format(iso8601(announcement['last_spoke']))
-  else:
-    offset = now
-  if announcement['start_date']:
-    if announcement['start_date'] > now:
-      offset = announcement['start_date']
-      text += ' scheduled for {}'.format(iso8601(announcement['start_date']))
-  if announcement['end_date']:
-    if announcement['end_date'] < now:
-      offset = None
-      text += ' has expired'
-  if offset is not None and offset <= now:
-    if announcement['interval']:
-      text += ' scheduled for {}'.format(iso8601(offset + announcement['interval']))
-  if announcement['asap'] == 'true':
-    text += ' will be given ASAP'
-  if announcement['probability'] < 0:
-    text += ' paused'
-  text += '**'
-  lines.append(text)
-
-  text = 'announce create'
-  if announcement['start_date']:
-    text += ' from "{}"'.format(iso8601(announcement['start_date']))
-  if announcement['end_date']:
-    text += ' to "{}"'.format(iso8601(announcement['end_date']))
-  if announcement['interval']:
-    text += ' every {}'.format(unparse_seconds(int(announcement['interval'])))
-  mention = announcement['mention']
-  if mention:
-    if mention == 'everyone' or mention == 'here':
-      text += ' tell @{}'.format(mention)
-    else:
-      if message.channel.is_private and mention[0] == '&':
-        # Can't mention a role in a private channel.
-        text += ' tell @{}'.format(mention)
-      else:
-        text += ' tell <@{}>'.format(mention)
-  if announcement['channel_id'] != announcement['voice_id']:
-    if announcement['channel_id'] == 'private':
-      text += ' in private'
-    else:
-      text += ' in <#{}>'.format(announcement['channel_id'])
-  if announcement['voice_id']:
-    text += ' voice <#{}>'.format(announcement['voice_id'])
-    if announcement['sound']:
-      text += ' play "{}"'.format(announcement['sound'])
-  if abs(announcement['probability']) < 1.0:
-    text += ' probability {}'.format(abs(announcement['probability']))
-
-  if announcement['message']:
-    text += ':'
-  lines.append(text)
-  if announcement['message']:
-    lines.append(announcement['message'])
-
-  yield from say(message.channel, '\n'.join(lines))
-
-# Map a database key to a syntax parameter.
-def announcement_key(k):
-  d = {
-    'channel_id': 'in',
-    'end_date': 'to',
-    'interval': 'every',
-    'mention': 'tell',
-    'start_date': 'from',
-    'voice_id': 'voice'
-  }
-  if k in d:
-    return d[k]
-  else:
-    return k
-
-# Map a database key to a syntax parameter.
-def feed_key(k):
-  d = {
-    'channel_id': 'in',
-    'description': 'title'
-  }
-  if k in d:
-    return d[k]
-  else:
-    return k
-
-@asyncio.coroutine
-def parse_announcement(message, raw, editing = False):
-  # We want to split by colon but don't want to count any which are part of dates or URLs.
-  m = re.match(r'(?:[^:]*\s+(?:(?:at|from|to)\s+"?\d\d\d\d-?\d\d-?\d\d(?:T|\s+)\d\d:?\d\d:?\d\dZ?"?|sound\s+https?:\S+))+', raw, re.IGNORECASE)
-  if m is not None:
-    parts = raw[len(m.group(0)):].split(':')
-    parts[0] = m.group(0) + parts[0]
-  else:
-    parts = raw.split(':')
-  params = parts[0]
-  if len(parts) > 1:
-    text = ':'.join(parts[1:]).strip()
-  else:
-    text = None
-
-  parsed = {}
-  if editing:
-    command = 'edit'
-  else:
-    command = 'create'
-    parsed['member_id'] = message.author.id
-  if text:
-    parsed['message'] = text
-
-  log.debug('announce {} params {} text {}'.format(command, params, text))
-  args = shlex.split(params)
-  log.info(args)
-  ok = False
-  i = 0
-  while i < len(args):
-    arg = args[i].lower()
-    if i > len(args) - 1:
-      break
-    try:
-      param = args[i + 1]
-    except IndexError:
-      param = None
-    log.info('{}: {}={}'.format(i, arg, param))
-    ok = False
-
-    if arg == command:
-      # UUID missing 'id'.
-      if editing and re.match(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', param):
-        parsed['id'] = param
-      else:
-        i -= 1
-      ok = True
-    elif arg == 'id':
-      if editing:
-        parsed['id'] = param
-        ok = True
-      else:
-        break
-    elif arg == 'once':
-      parsed['interval'] = 0
-      i -= 1
-      ok = True
-    elif arg in ['at', 'from', 'to']:
-      k = 'end_date' if arg == 'to' else 'start_date'
-      if editing and param == 'none':
-        parsed[k] = None
-        ok = True
-      else:
-        m = re.match(r'(\d\d\d\d)-?(\d\d)-?(\d\d)(?:T|\s+)(\d\d):?(\d\d):?(\d\d)Z?', param)
-        if m is not None:
-          parsed[k] = int(datetime.datetime(*([int(n) for n in m.groups()] + [0, pytz.UTC])).timestamp())
-          ok = True
-        else:
-          break
-      if arg == 'at':
-        parsed['end_date'] = parsed['start_date']
-        parsed['interval'] = 0
-    elif arg == 'every':
-      seconds = parse_seconds(param)
-      if seconds is not None:
-        parsed['interval'] = seconds
-        ok = True
-      else:
-        break
-    elif arg == 'tell':
-      if editing and param == 'none':
-        parsed['mention'] = None
-        ok = True
-      elif param == 'me':
-        parsed['mention'] = message.author.id
-        if 'channel_id' not in parsed:
-          parsed['channel_id'] = 'private'
-        ok = True
-      else:
-        m = re.match(r'<@((?:&?|#)\d+)>', param)
-        if m is None:
-          m = re.match(r'@((?:&?|#)\d+)', param)
-          if m is None:
-            m = re.match('r@(everyone|here)', param)
-        if m is not None:
-          parsed['mention'] = m.group(1)
-          ok = True
-        else:
-          break
-    elif arg in ['in', 'voice']:
-      k = 'channel_id' if arg == 'in' else 'voice_id'
-      if editing and param == 'none':
-        parsed['mention'] = None
-        ok = True
-      elif param == 'here':
-        if arg != 'in':
-          break
-        if message.channel.is_private:
-          parsed[k] = 'private'
-          ok = True
-        else:
-          parsed[k] = message.channel.id
-          ok = True
-      elif param == 'private':
-        if arg != 'in':
-          break
-        parsed[k] = 'private'
-        ok = True
-      else:
-        m = re.match(r'<#(\d+)>', param)
-        if m is None:
-          m = re.match(r'#(\d+)', param)
-        if m is not None:
-          parsed[k] = m.group(1)
-          ok = True
-        else:
-          m = re.match(r'#(.+)', param)
-          if m is None:
-            break
-          # Look for a channel with that name.
-          for channel in client.get_all_channels():
-            if channel.name == m.group(1):
-              parsed[k] = channel.id
-              ok = True
-              break
-    elif arg == 'sound':
-      if editing and param == 'none':
-        parsed['mention'] = None
-        ok = True
-      else:
-        parsed['sound'] = param
-        ok = True
-    elif arg == 'probability':
-      parsed['probability'] = param
-      ok = True
-    else:
-      yield from say(message.channel, 'What is {}?'.format(arg))
-      return
-
-    if ok:
-      i += 2
-    else:
-      break
-
-  if not ok:
-    log.info('Failed to parse announcement.  Got: {}'.format(parsed))
-    yield from say(message.channel, '{}?'.format(arg))
-    return None
-
-  if 'channel_id' in parsed:
-    if parsed['channel_id'] == 'private':
-      parsed['server_id'] = 'private'
-      if 'mention' in parsed:
-        if parsed['mention'] != message.author.id:
-          yield from say(message.channel, "Can't mention someone else in private message!")
-          return None
-      parsed['mention'] = None
-      if 'voice_id' in parsed or 'sound' in parsed:
-        yield from say(message.channel, 'No sounds for private messages!')
-        return None
-    else:
-      channel = client.get_channel(parsed['channel_id'])
-      if not channel:
-        yield from say(message.channel, 'Invalid channel!')
-        return None
-      server = channel.server
-      parsed['server_id'] = server.id
-
-  if 'start_date' in parsed and 'end_date' in parsed:
-    if parsed['start_date'] > parsed['end_date']:
-      yield from say(message.channel, "Start date must not be before end date!")
-      return None
-
-  if editing:
-    if 'id' not in parsed:
-      yield from say(message.channel, 'Missing ID!')
-      return None
-  else:
-    if 'channel_id' not in parsed:
-      if 'voice_id' in parsed:
-        parsed['channel_id'] = parsed['voice_id']
-      else:
-        yield from say(message.channel, 'Missing channel!')
-        return None
-
-  return parsed
-
-@asyncio.coroutine
-def create_announcement(message, raw):
-  # announcement create [params]: <text>
-  create = yield from parse_announcement(message, raw)
-  if create is None:
-    return
-
-  announce = yield from can_manage_announcements(message.author, message.channel, 'create', create = create)
-  if not announce:
-    return
-
-  id = db.create_announcement(client, **create)
-  if id:
-    create['id'] = id
-    log.info('Created announcement: {}'.format(create))
-    yield from say(message.channel, id)
-    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-  else:
-    log.info('Failed to create announcement: {}'.format(create))
-    yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def edit_announcement(message, raw):
-  update = yield from parse_announcement(message, raw, True)
-  if update is None:
-    return
-
-  id = update['id']
-  del(update['id'])
-  announce = yield from can_manage_announcements(message.author, message.channel, 'edit', id = id)
-  if not announce:
-    return
-
-  if not len(update.keys()):
-    yield from say(message.channel, '?')
-    return
-
-  if db.update_announcement(client, id, **update):
-    log.info('Edited announcement {}: {}'.format(id, update))
-    yield from say(message.channel, ', '.join([announcement_key(k) for k in update]))
-    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-  else:
-    log.info('Failed to edit announcement {}: {}'.format(id, update))
-    yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def delete_announcement(message, id):
-  announcement = yield from can_manage_announcements(message.author, message.channel, 'delete', id = id)
-  if not announcement:
-    return
-  if dryrun:
-    log.info('Not deleting announcement {}'.format(id))
-  else:
-    log.info('Deleting announcement {}'.format(id))
-    if db.delete_announcement(client, id):
-      yield from say(message.channel, 'purr')
-    else:
-      yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def schedule_announcement(message, id, **args):
-  announcement = yield from can_manage_announcements(message.author, message.channel, 'schedule', id = id)
-  if not announcement:
-    return
-
-  update = {}
-
-  # Pause announcement.
-  if 'pause' in args:
-    if args['pause']:
-      update['probability'] = -abs(announcement['probability'])
-    else:
-      update['probability'] = abs(announcement['probability'])
-
-  # ASAP.
-  if 'asap' in args:
-    update['asap'] = 'true'
-
-  if dryrun:
-    log.info('Not updating announcement {}: {}'.format(id, update))
-  else:
-    log.info('Updating announcement {}: {}'.format(id, update))
-    if db.update_announcement(client, id, **update):
-      yield from say(message.channel, 'purr')
-    else:
-      yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def manage_announcements(message, command, raw):
-  log.debug('Command: {}'.format(raw))
-
-  m = re.match(r'announce(?:ment)?\s+(.+)', raw, re.IGNORECASE | re.DOTALL)
-  if m is None:
-    yield from list_announcements(message)
-    return
-  text = m.group(1)
-  args = shlex.split(text)
-  command = args[0].lower()
-  if len(args) == 1:
-    if command == 'list':
-      yield from list_announcements(message)
-      return
-    elif command == 'help':
-      yield from help_announcements(message)
-      return
-    else:
-      yield from say(message.channel, 'yelp!')
-      return
-
-  if command == 'show':
-    yield from show_announcement(message, args[1])
-  elif command == 'help':
-    yield from help_announcements(message, args[1])
-  elif command == 'delete':
-    yield from delete_announcement(message, args[1])
-  elif command == 'asap':
-    yield from schedule_announcement(message, args[1], asap = True)
-  elif command == 'pause':
-    yield from schedule_announcement(message, args[1], pause = True)
-  elif command == 'resume':
-    yield from schedule_announcement(message, args[1], pause = False)
-  elif command == 'create':
-    yield from create_announcement(message, text)
-  elif command == 'edit':
-    yield from edit_announcement(message, text)
-
-@asyncio.coroutine
-def announce(announcement):
-  update = {}
-  # Adhere to schedule.
-  if announcement['asap'] == 'true':
-    log.info('Announcement {} was requested ASAP'.format(announcement['id']))
-    announcement['probability'] = 1.0
-    update['asap'] = 'false'
-  elif announcement['interval']:
-    if announcement['start_date'] or announcement['last_spoke']:
-      now = int(time.time())
-      if announcement['start_date']:
-        start = announcement['start_date']
-      else:
-        start = announcement['last_spoke']
-      intervals = math.floor((now - start) / announcement['interval'])
-      if intervals:
-        # We missed a schedule.
-        scheduled = start + intervals * announcement['interval']
-        if now - scheduled > max(60, announcement['interval'] / 2):
-          log.info('Announcement {} should have been given at {}'.format(announcement['id'], iso8601(scheduled)))
-          if 'digest' in announcement:
-            update['digest'] = announcement['digest']
-            update['last_spoke'] = scheduled
-          db.update_announcement(client, announcement['id'], **update)
-          return
-      # Give the announcement now but set the original schedule.
-      last_spoke = start + (intervals + 1) * announcement['interval']
-      if last_spoke < now:
-        update['last_spoke'] = last_spoke
-        log.info('Forcing last_spoke for announcement {} to {} in line with schedule'.format(announcement['id'], iso8601(update['last_spoke'])))
-  if 'asap' not in update and 'last_spoke' not in update:
-    update['last_spoke'] = int(time.time())
-
-  channel = None
-  channel_id = str(announcement['channel_id'])
-  private = channel_id == 'private'
-  if private:
-    for server in client.servers:
-      try:
-        member = server.get_member(announcement['member_id'])
-        channel = yield from client.start_private_message(member)
-        break
-      except:
-        pass
-  else:
-    channel = client.get_channel(channel_id)
-  if channel is None:
-    log.warning("Can't get channel for announcement {}".format(announcement['id']))
-    # Set last_spoke so we don't spam.
-    db.update_announcement(client, announcement['id'], **update)
-    return
-
-  voice_only = announcement['voice_id'] == announcement['channel_id']
-  already_posted = False
-  if not voice_only:
-    # @everyone mention mangles the digest
-    text = announcement['message']
-    mention = announcement['mention']
-    if mention:
-      if mention in ['everyone', 'here']:
-        text = '@{}\n{}'.format(mention, text)
-      else:
-        text = '<@{}>\n{}'.format(mention, text)
-
-    digest = announcement['digest']
-
-    # Always post private messages.  Try not to spam public announcements.
-    if not private:
-      cutoff = datetime.datetime.utcnow() - datetime.timedelta(0, announcement['interval'])
-      result = yield from client.logs_from(channel, after = cutoff)
-      logs = list(result)
-      # Don't spam the same message in a quiet channel even if it hasn't
-      # been posted since the cutoff.
-      if len(logs) < message_limit.value:
-        logs = yield from client.logs_from(channel, limit = message_limit.value)
-      for message in logs:
-        raw = re.sub(r'^(<?@\S+\s)+', '', message.content).strip()
-        if hashlib.sha224(raw.encode('utf-8')).hexdigest() == digest:
-          log.debug('Saw previously posted announcement {} with digest {}'.format(announcement['id'], digest))
-          already_posted = True
-          break
-
-  if announcement['voice_id']:
-    filename = announcement['sound']
-    if not filename:
-      filename = 'meow.wav'
-
-  try:
-    if dryrun:
-      if not already_posted:
-        if voice_only:
-          log.info('Dryrun: Not playing announcement {} {} in {}'.format(announcement['id'], filename, channel))
-        else:
-          log.info('Dryrun: Not posting announcement {} to {}: {}'.format(announcement['id'], channel, text))
-    else:
-      announce = random.random() <= announcement['probability'] if not already_posted else 0
-      if announce:
-        if voice_only:
-          yield from wake_up()
-        else:
-          log.info('Posting announcement {} to {}: {}'.format(announcement['id'], channel, text))
-          message = yield from say(channel, text)
-          update['digest'] = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
-      else:
-        if announcement['probability'] < 0:
-          log.info("Announcement {} is paused".format(announcement['id']))
-        else:
-          log.info("Didn't bother with announcement {}".format(announcement['id']))
-      # Set last_spoke even if we chose not to announce.
-      db.update_announcement(client, announcement['id'], **update)
-      if not announce:
-        return
-      if announcement['voice_id']:
-        log.info('Playing announcement {} {} in {}'.format(announcement['id'], filename, channel))
-        yield from play_sound(client.get_channel(str(announcement['voice_id'])), filename)
-  except:
-    logging.exception("announce")
-
-@asyncio.coroutine
-def do_announcements():
-  waittime = 60
-  while True:
-    # Convert to list because we will be sharing the cursor.
-    for announcement in list(db.get_announcements(client)):
-      yield from announce(announcement)
-
-    # Delete old announcements.
-    now = int(time.time())
-    for announcement in db.get_all_announcements(client):
-      if announcement['asap'] == 'true':
-        # Don't delete announcements that were requested ASAP.
-        continue
-      if not announcement['last_spoke']:
-        # Don't delete announcements that haven't played yet.
-        continue
-      if now - announcement['last_spoke'] < max(announcement['interval'], 86400):
-        # Don't delete announcements that aren't old.
-        continue
-      if announcement['interval']:
-        if not announcement['end_date']:
-          # Don't delete ongoing announcements.
-          continue
-        if announcement['end_date'] > now:
-          # Don't delete announcements that still have time to run.
-          continue
-      if dryrun:
-        log.info("Would delete old announcement {}".format(announcement['id']))
-      else:
-        if db.delete_announcement(client, announcement['id']):
-          log.info('Deleted old announcement {}'.format(announcement['id']))
-        else:
-          log.info('Failed to delete old announcement {}'.format(announcement['id']))
-
-    yield from asyncio.sleep(waittime)
-
-@asyncio.coroutine
-def can_manage_feeds(author, channel, command, **args):
-  # Anyone can list feeds.
-  if command == 'list':
-    log.debug('Anyone can list feeds.')
-    return True
-
-  if 'id' in args:
-    feed = db.get_feed(client, args['id'])
-  elif 'create' in args:
-    feed = args['create']
-  else:
-    feed = None
-
-  # Anyone on the server can show details of an announcement.
-  if command == 'show':
-    if feed is not None:
-      server = client.get_server(feed['server_id'])
-      if server is not None:
-        member = server.get_member(author.id)
-        if member in server.members:
-          log.debug('Member {} on server {} can show feed {}.'.format(member.name, server.name, feed['id']))
-          return feed
-
-  if command in ['create', 'edit', 'delete', 'schedule']:
-    if feed is not None:
-      server = client.get_server(feed['server_id'])
-      member = server.get_member(author.id)
-      bot = server.get_member(client.user.id)
-      member_role = highest_role(member.roles)
-      bot_role = highest_role(bot.roles)
-      if member_role.position >= bot_role.position:
-        log.debug('Member {} with role {} on server {} can manage feeds.'.format(member.name, member_role.name, server.name))
-        return feed
-
-  yield from say(channel, 'hiss!')
-  return False
-
-@asyncio.coroutine
-def help_feeds(message, command = None):
-  if command is None:
-    lines = [
-      'Commands to manage news feeds are:',
-      '```',
-      'create',
-      'delete',
-      'edit',
-      'list',
-      'pause',
-      'show',
-      'resume',
-      '```',
-      'Send `feed help COMMAND` for help on a specific command.'
-    ]
-  elif command == 'create':
-    lines = [
-      'Create a news feed.',
-      '```feed create title TITLE in CHANNEL url URL OPTIONS```',
-      'Here are the required arguments and `OPTIONS`:',
-      '',
-      '```title TITLE```',
-      'A description of the news feed, eg GalNet.',
-      '',
-      '```in CHANNEL```',
-      'Post the feed to the specified #channel.',
-      '',
-      '```url URL```',
-      'The URL to retrieve the feed in RSS format.',
-      '',
-      '```date false```',
-      "Use this if the dates returned by the RSS URL aren't valid.  For instance the official GalNet feed always returns the date its cache was updated NOT the date the story was posted.",
-      '',
-      '```summary true```',
-      'Include part of the text from the feed in the post.',
-      '',
-    ]
-  elif command == 'delete':
-    lines = [
-      'Delete the feed with the given ID.',
-      '```feed delete ID```',
-    ]
-  elif command == 'edit':
-    lines = [
-      'Edit a feed.',
-      '```feed edit ID OPTIONS```',
-      'Change one or more `OPTIONS` for the feed with the given ID.',
-      'See the help for `feed create` for details of the OPTIONS you can set.',
-    ]
-  elif command == 'help':
-    lines = ['grr!']
-  elif command == 'list':
-    lines = [
-      'List all feeds, one `ID` per line.  You can use the `ID` in other commands.',
-      "When listing IDs to a public channel I won't show IDs for feeds that are for channels on another server.  Send `announce list` to me in a private message to see them.",
-    ]
-  elif command == 'show':
-    lines = [
-      'Show the feed with the given ID.',
-      '```announcement show ID```',
-      'I will tell you the details in a format which you could copy and paste to create a new feed.',
-    ]
-  elif command in ['pause', 'resume']:
-    lines = [
-      'Schedule the feed with the given ID.',
-      '```',
-      'feed pause ID',
-      'feed resume ID',
-      '```',
-      'Use `pause` and `resume` to put a feed on hold temporarily.'
-    ]
-  else:
-    lines = ['*shrugs*']
-  yield from say_many(message.channel, lines)
-
-@asyncio.coroutine
-def list_feeds(message):
-  result = yield from can_manage_feeds(message.author, message.channel, 'list')
-  if not result:
-    return
-  servers = []
-  if message.channel.is_private:
-    for server in client.servers:
-      if message.author in server.members:
-        servers.append(server)
-  else:
-    servers = [message.channel.server]
-
-  results = []
-  for feed in db.get_all_feeds(client, servers):
-    text = '**{}** url {} in <#{}>'.format(feed['id'], feed['url'], feed['channel_id'])
-    results.append(text)
-
-  if len(results):
-    yield from say_many(message.channel, results)
-  else:
-    yield from say(message.channel, '*shrugs*')
-
-@asyncio.coroutine
-def show_feed(message, id):
-  feed = yield from can_manage_feeds(message.author, message.channel, 'show', id = id)
-  if not feed:
-    return
-  lines = []
-  text = '**feed {}'.format(id)
-  if not parse_boolean(feed['enabled']):
-    text += ' paused'
-  text += '**'
-  lines.append(text)
-
-  text = 'feed create'
-  text += ' title "{}"'.format(feed['description'])
-  text += ' url {}'.format(feed['url'])
-  text += ' in <#{}>'.format(feed['channel_id'])
-  text += ' date {}'.format(feed['date'])
-  text += ' summary {}'.format(feed['summary'])
-  if feed['link_json']:
-    text += "--link_json '{}'".format(feed['link_json'])
-  lines.append(text)
-
-  yield from say(message.channel, '\n'.join(lines))
-
-@asyncio.coroutine
-def parse_feed(message, raw, editing = False):
-  parsed = {}
-  if editing:
-    command = 'edit'
-  else:
-    command = 'create'
-
-  log.debug('feed {} params {}'.format(command, raw))
-  args = shlex.split(raw)
-  log.info(args)
-  ok = False
-  i = 0
-  while i < len(args):
-    arg = args[i].lower()
-    if i > len(args) - 1:
-      break
-    try:
-      param = args[i + 1]
-    except IndexError:
-      param = None
-    log.info('{}: {}={}'.format(i, arg, param))
-    ok = False
-
-    if arg == command:
-      # UUID missing 'id'.
-      if editing and re.match(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', param):
-        parsed['id'] = param
-      else:
-        i -= 1
-      ok = True
-    elif arg == 'id':
-      if editing:
-        parsed['id'] = param
-        ok = True
-      else:
-        break
-    elif arg == 'title':
-      k = 'description'
-      parsed[k] = param
-      ok = True
-    elif arg == 'channel':
-      k = 'channel_id'
-      m = re.match(r'<#(\d+)>', param)
-      if m is None:
-        m = re.match(r'#(\d+)', param)
-      if m is not None:
-        parsed[k] = m.group(1)
-        ok = True
-      else:
-        m = re.match(r'#(.+)', param)
-        if m is None:
-          break
-        # Look for a channel with that name.
-        for channel in client.get_all_channels():
-          if channel.name == m.group(1):
-            parsed[k] = channel.id
-            ok = True
-            break
-    elif arg == 'url':
-      k = 'url'
-      m = re.match(r'https?://', str(param))
-      if m is not None:
-        parsed[k] = param
-        ok = True
-      else:
-        break
-    elif arg in ['date', 'summary']:
-      k = arg
-      if param in ['true', 'false']:
-        parsed[k] = param
-        ok = True
-      else:
-        break
-    else:
-      yield from say(message.channel, 'What is {}?'.format(arg))
-      return
-
-    if ok:
-      i += 2
-    else:
-      break
-
-  if not ok:
-    log.info('Failed to parse feed.  Got: {}'.format(parsed))
-    yield from say(message.channel, '{}?'.format(arg))
-    return None
-
-  if 'channel_id' in parsed:
-    channel = client.get_channel(parsed['channel_id'])
-    if not channel:
-      yield from say(message.channel, 'Invalid channel!')
-      return None
-    server = channel.server
-    parsed['server_id'] = server.id
-
-  if editing:
-    if 'id' not in parsed:
-      yield from say(message.channel, 'Missing ID!')
-      return None
-  else:
-    if 'channel_id' not in parsed:
-      yield from say(message.channel, 'Missing channel!')
-      return None
-    if 'url' not in parsed:
-      yield from say(message.channel, 'Missing URL!')
-      return None
-    if 'description' not in parsed:
-      yield from say(message.channel, 'Missing title!')
-      return None
-
-
-  return parsed
-
-@asyncio.coroutine
-def create_feed(message, raw):
-  # feed create [params]: <text>
-  create = yield from parse_feed(message, raw)
-  if create is None:
-    return
-
-  feed = yield from can_manage_feeds(message.author, message.channel, 'create', create = create)
-  if not feed:
-    return
-
-  id = db.create_feed(client, **create)
-  if id:
-    create['id'] = id
-    log.info('Created feed: {}'.format(create))
-    yield from say(message.channel, id)
-    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-  else:
-    log.info('Failed to create feed: {}'.format(create))
-    yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def edit_feed(message, raw):
-  update = yield from parse_feed(message, raw, True)
-  if update is None:
-    return
-
-  id = update['id']
-  del(update['id'])
-  feed = yield from can_manage_feeds(message.author, message.channel, 'edit', id = id)
-  if not feed:
-    return
-
-  if not len(update.keys()):
-    yield from say(message.channel, '?')
-    return
-
-  if db.update_feed(client, id, **update):
-    log.info('Edited feed {}: {}'.format(id, update))
-    yield from say(message.channel, ', '.join([feed_key(k) for k in update]))
-    yield from play_sound(voice_channel_for_channel(message.channel), 'purr.wav', join = False)
-  else:
-    log.info('Failed to edit feed {}: {}'.format(id, update))
-    yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def delete_feed(message, id):
-  feed = yield from can_manage_feeds(message.author, message.channel, 'delete', id = id)
-  if not feed:
-    return
-  if dryrun:
-    log.info('Not deleting feed {}'.format(id))
-  else:
-    log.info('Deleting feed {}'.format(id))
-    if db.delete_feed(client, id):
-      yield from say(message.channel, 'purr')
-    else:
-      yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def schedule_feed(message, id, **args):
-  feed = yield from can_manage_feeds(message.author, message.channel, 'schedule', id = id)
-  if not feed:
-    return
-
-  update = {}
-
-  # Pause feed.
-  if 'pause' in args:
-    if args['pause']:
-      update['enabled'] = 'false'
-    else:
-      update['enabled'] = 'true'
-
-  if dryrun:
-    log.info('Not updating feed {}: {}'.format(id, update))
-  else:
-    log.info('Updating feed {}: {}'.format(id, update))
-    if db.update_feed(client, id, **update):
-      yield from say(message.channel, 'purr')
-    else:
-      yield from say(message.channel, 'yelp!')
-
-@asyncio.coroutine
-def manage_feeds(message, command, raw):
-  log.debug('Command: {}'.format(raw))
-
-  m = re.match(r'(?:news|feed)?\s+(.+)', raw, re.IGNORECASE | re.DOTALL)
-  if m is None:
-    yield from list_feeds(message)
-    return
-  text = m.group(1)
-  args = shlex.split(text)
-  command = args[0].lower()
-  if len(args) == 1:
-    if command == 'list':
-      yield from list_feeds(message)
-      return
-    elif command == 'help':
-      yield from help_feeds(message)
-      return
-    else:
-      yield from say(message.channel, 'yelp!')
-      return
-
-  if command == 'show':
-    yield from show_feed(message, args[1])
-  elif command == 'help':
-    yield from help_feeds(message, args[1])
-  elif command == 'delete':
-    yield from delete_feed(message, args[1])
-  elif command == 'pause':
-    yield from schedule_feed(message, args[1], pause = True)
-  elif command == 'resume':
-    yield from schedule_feed(message, args[1], pause = False)
-  elif command == 'create':
-    yield from create_feed(message, text)
-  elif command == 'edit':
-    yield from edit_feed(message, text)
-
-@asyncio.coroutine
-def maybe_sleep():
-  while True:
-    if status() != discord.Status.idle:
-      now = time.time()
-      if now - last_spoke.value >= idletime.value:
-        yield from zzz()
-    yield from asyncio.sleep(60)
-
-p = pexpect.spawnu('./edi.py')
-wait_for_prompt(p)
-
-modules = {
-  'admin': {
-    'fn': do_commands,
-    'args': [False],
-    'commands': ['avatar', 'debug', 'delete', 'dryrun', 'idle', 'rss']
-  },
-  'announcements': {
-    'fn': manage_announcements,
-    'args': [],
-    'commands': ['announce', 'announcement']
-  },
-  'feeds': {
-    'fn': manage_feeds,
-    'args': [],
-    'commands': ['news', 'feed']
-  },
-  'any': {
-    'fn': do_commands,
-    'args': [True],
-    'commands': ['bling', 'fuss', 'help', 'id', 'stroke', 'time', 'treat', 'pat']
-  },
-  'edi': {
-    'fn': do_edi,
-    'args': [p],
-    'commands': ['coords', 'close_to', 'distance', 'edts', 'find', 'fuel_usage', 'galmath', 'raikogram']
-  },
-  'fork': {
-    'fn': do_fork,
-    'args': [],
-    'commands': ['gravity', 'material', 'range']
-  }
-}
-
-last_spoke = multiprocessing.Value('f', time.time())
-client = discord.Client()
-
-@asyncio.coroutine
-def process_module(message, command, raw):
-  for module, params in modules.items():
-    if command in params['commands']:
-      log.info('Recognised command "{}" from {} in "{}" module'.format(command, message.author.name, module))
-      fn = params['fn']
-      args = params['args']
-      yield from fn(message, command, raw, *args)
-      return
-  log.debug('Unrecognised command from {}!'.format(message.author.name))
-  if status() == discord.Status.idle:
-    yield from maybe_say(message.channel, 'zzz', wake = False)
-  else:
-    yield from maybe_say(message.channel, 'meow!')
-
-def channels_to_greet():
-  greetings = get_bot_variable('greetings', [])
-  if not len(greetings):
-    return []
-  channels = []
-  for channel in client.get_all_channels():
-    if channel.is_private:
-      log.debug('Not a channel to greet: {} is private'.format(channel))
-      continue
-    if int(channel.id) not in [greeting['channel'] for greeting in greetings]:
-      log.debug('Not a channel to greet: {} not in {}'.format(channel, [greeting['channel'] for greeting in greetings]))
-      continue
-    log.debug('Channel to greet: {}'.format(channel))
-    channels.append(channel)
-  return channels
-
-def should_greet_member_in(channel, member, role_name = 'Unassigned', minimum = 1):
-  if member not in channel.server.members:
-    log.debug('Not greeting {} not in server memberlist'.format(member))
-    return False
-  permissions = channel.permissions_for(member)
-  if permissions is None:
-    log.debug('Not greeting {} with no permissions'.format(member))
-    return False
-  if not permissions.read_messages:
-    log.debug('Not greeting {} with no read message permission'.format(member))
-    return False
-  if role_name in [role.name for role in member.roles]:
-    log.debug('Not greeting {} with {} role already'.format(member, role_name))
-    return False
-  if len(member.roles) > minimum:
-    log.debug('Not greeting {} with more than {} roles'.format(member, minimum))
-    return False
-  log.info('Greeting {} in {}'.format(member, channel.name))
-  return True
-
-def voice_channel_for_channel(channel):
-  for voice in client.voice_clients:
-    if voice.server != channel.server:
-      continue
-    if not voice.is_connected:
-      continue
-    return voice.channel
-
-@asyncio.coroutine
-def maybe_play_sound(channel, filename, *, probability = 0.05, join = True):
-  if not channel or not filename:
-    if join:
-      log.warning('Missing channel and/or filename for play_sound()')
-    return False
-
-  if random.random() > probability:
-    log.debug("Didn't bother to play {}".format(filename))
-    return False
-
-  while playing.value:
-    yield from asyncio.sleep(1)
-
-  playing.value = True
-  voice = client.voice_client_in(channel.server)
-  if voice is None or voice.channel != channel:
-    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:
-      yield from client.move_to(channel)
-    else:
-      yield from 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))
-      playing.value = False
-      return False
-  try:
-    log.info('Playing {} in channel {}'.format(filename, channel))
-    player = voice.create_ffmpeg_player(filename)
-    player.start()
-    while not player.is_done():
-      yield from asyncio.sleep(1)
-    playing.value = False
-    log.info('Finished playing {} in channel {}'.format(filename, channel))
-  except:
-    log.error("Error creating ffpmeg player for {}".format(filename))
-    playing.value = False
-    return False
-
-@asyncio.coroutine
-def play_sound(channel, filename, *, join = True):
-  result = yield from maybe_play_sound(channel, filename, probability = 1.0, join = join)
-  return result
-
-@asyncio.coroutine
-def play_greeting(greeting):
-  if 'voice' not in greeting:
-    return
-  yield from play_sound(client.get_channel(str(greeting['voice'])), greeting['sound'])
-
-@client.event
-@asyncio.coroutine
-def on_ready():
-  state = db.get_state(client)
-  if state is not None:
-    if state['idle']:
-      log.info('Restoring idle status.')
-      yield from set_idle(True)
-      if state['avatar'] != db.IDLE_AVATAR:
-        log.info('Restoring idle avatar.')
-        yield from set_avatar(db.IDLE_AVATAR)
-    elif state['avatar'] != db.ONLINE_AVATAR:
-      log.info('Restoring online avatar.')
-      yield from set_avatar(db.ONLINE_AVATAR)
-    if state['last_spoke']:
-      last_spoke.value = state['last_spoke']
-  log.info('Logged in as {}#{}'.format(client.user.name, client.user.id))
-  log.info('Admins are: {}'.format(admins))
-  channels = channels_to_greet()
-  log.info('Greeting in {}'.format([channel.name for channel in channels]))
-  for channel in channels:
-    log.debug('Greeting in {}'.format(channel.name))
-    sound = True
-    for member in channel.server.members:
-      result = should_greet_member_in(channel, member)
-      if not result:
-        continue
-      yield from greet(channel, member, sound)
-      sound = False
-  for member in client.get_all_members():
-    log.debug('Maybe unassigning {}'.format(member.name))
-    yield from set_unassigned(member)
-  if not threads_ready.value:
-    asyncio.async(do_feeds())
-    asyncio.async(do_announcements())
-    asyncio.async(maybe_sleep())
-    threads_ready.value = True
-
-def mentioned_in(message, explicit = True):
-  if message.channel.is_private:
-    return True
-  if not client.user.mentioned_in(message):
-    return False
-  if explicit:
-    for member in message.mentions:
-      if member.id == client.user.id:
-        return True
-    return False
-  return True
-
-@client.event
-@asyncio.coroutine
-def on_message(message):
-  if message.author.id == client.user.id:
-    return
-  log.debug('Got message from {}: {}'.format(message.author, message.content))
-  if not mentioned_in(message):
-    log.debug('Message is not for me!')
-    return
-  raw = re.sub('<@{}>'.format(client.user.id), '', message.content).strip()
-  m = re.match(r'^\s*(\S+)', raw)
-  if m is None:
-    return
-  command = m.group(1)
-  yield from process_module(message, command, raw)
-
-@client.event
-@asyncio.coroutine
-def on_member_join(member):
-  channels = channels_to_greet()
-  for channel in channels:
-    result = should_greet_member_in(channel, member)
-    if result:
-      yield from greet(channel, member)
-  yield from set_unassigned(member)
-
-client.run(token)
diff --git a/db.py b/db.py
index 94a7530..e091b4c 100644 (file)
--- a/db.py
+++ b/db.py
@@ -1,3 +1,4 @@
+import asyncio
 import discord
 import logging
 import sqlite3
@@ -7,8 +8,8 @@ import uuid
 log = logging.getLogger('db')
 
 class DBConnection(object):
-  def __init__(self, filename = 'bot.sqlite'):
-    self.filename = filename
+  def __init__(self, filename = None):
+    self.filename = filename if filename is not None else 'bot.sqlite'
     self.ONLINE_AVATAR = 'avatar_online.png'
     self.IDLE_AVATAR = 'avatar_idle.png'
     self.create_tables()
@@ -36,9 +37,6 @@ class DBConnection(object):
     cursor = self.query('create table if not exists announcements (id char(36) not null, bot_id varchar(32) not null, server_id varchar(32) not null, member_id varchar(32) not null, channel_id varchar(32) not null, voice_id varchar(32), sound varchar(128), mention varchar(32), start_date datetime, end_date datetime, interval int, probability float not null default 1.0, last_spoke datetime, asap boolean not null default false, message text, digest char(56))')
     cursor = self.query('create unique index if not exists announcements_id on announcements (id)')
     cursor = self.query('create index if not exists announcements_bot_id on announcements (bot_id)')
-    cursor = self.query('create table if not exists feeds (id char(36) not null, bot_id varchar(32) not null, server_id varchar(32) not null, channel_id varchar(32) not null, description varchar(64), url varchar(128) not null, date boolean not null default true, summary boolean not null default false, link_json varchar(256), enabled boolean not null default true)')
-    cursor = self.query('create unique index if not exists feed_id on feeds (id)')
-    cursor = self.query('create index if not exists feed_bot_id on feeds (bot_id)')
     self.dbh.commit()
     self.close_db()
 
@@ -176,24 +174,7 @@ class DBConnection(object):
   def delete_announcement(self, client, id):
     return self.delete_from_table(client, 'announcements', id)
 
-  def get_all_feeds(self, client, servers = []):
-    yield from self.get_all_from_table(client, 'feeds', servers)
-
-  def get_feeds(self, client):
-    now = self.now()
-    cursor = self.query("select id, server_id, channel_id, url, date, summary, link_json, enabled from feeds where bot_id=?", [client.user.id])
-    for row in cursor.fetchall():
-      yield dict(row)
-    self.close_db()
-
-  def get_feed(self, client, id):
-    return self.get_from_table(client, 'feeds', id)
-
-  def create_feed(self, client, **args):
-    return self.insert_into_table(client, 'feeds', **args)
-
-  def update_feed(self, client, id, **args):
-    return self.update_table(client, 'feeds', id, **args)
-
-  def delete_feed(self, client, id):
-    return self.delete_from_table(client, 'feeds', id)
+  @asyncio.coroutine
+  def on_ready(self):
+    # Override me.
+    return
diff --git a/plugins/__init__.py b/plugins/__init__.py
new file mode 100644 (file)
index 0000000..17c0175
--- /dev/null
@@ -0,0 +1,42 @@
+import glob
+import importlib
+import logging
+import os
+
+log = logging.getLogger('plugins')
+
+class Plugins(object):
+  def __init__(self):
+    self.loaded = {}
+    self.refresh()
+
+  def refresh(self):
+    for py in glob.glob('plugins/*.py'):
+      path = py.split('.py')[0]
+      plugin = os.path.basename(path)
+      if plugin == '__init__':
+        continue
+      name = path.replace('/', '.')
+      plugin = plugin[0].upper() + plugin[1:]
+      try:
+        log.debug('from {} import {}'.format(name, plugin))
+        if name in self.loaded:
+          module = importlib.reload(self.loaded[name]['module'])
+        else:
+          module = importlib.import_module(name)
+        self.loaded[name] = { 'module': module, 'plugin': getattr(module, plugin)() }
+      except:
+        logging.exception('refresh')
+
+  def log_level(self, level):
+    log.setLevel(level)
+    for name in self.loaded.keys():
+      logging.getLogger(name).setLevel(level)
+
+  def set_client(self, client):
+    for name, data in self.loaded.items():
+      data['module'].client = client
+
+  def all(self):
+    for name, data in self.loaded.items():
+      yield data['plugin']
diff --git a/plugins/feeds.py b/plugins/feeds.py
new file mode 100644 (file)
index 0000000..97e02c0
--- /dev/null
@@ -0,0 +1,168 @@
+import asyncio
+import bs4
+import discord
+import feedparser
+import hashlib
+import json
+import logging
+import multiprocessing
+import time
+
+from db import DBConnection
+import bot
+
+log = logging.getLogger('feeds')
+
+rsstime = multiprocessing.Value('i', 600)
+
+class Feeds(DBConnection):
+  def __init__(self, filename = None):
+    super(Feeds, self).__init__(filename)
+    self.create_tables()
+
+  def create_tables(self):
+    self.open_db()
+    cursor = self.query('create table if not exists feeds (id char(36) not null, bot_id varchar(32) not null, server_id varchar(32) not null, channel_id varchar(32) not null, description varchar(64), url varchar(128) not null, date boolean not null default true, summary boolean not null default false, link_json varchar(256), enabled boolean not null default true)')
+    cursor = self.query('create unique index if not exists feed_id on feeds (id)')
+    cursor = self.query('create index if not exists feed_bot_id on feeds (bot_id)')
+    self.dbh.commit()
+
+  def get_all_feeds(self, client, servers = []):
+    yield from self.get_all_from_table(client, 'feeds', servers)
+
+  def get_feeds(self, client):
+    now = self.now()
+    cursor = self.query("select id, server_id, channel_id, url, date, summary, link_json, enabled from feeds where bot_id=?", [client.user.id])
+    for row in cursor.fetchall():
+      yield dict(row)
+    self.close_db()
+
+  def get_feed(self, client, id):
+    return self.get_from_table(client, 'feeds', id)
+
+  def create_feed(self, client, **args):
+    return self.insert_into_table(client, 'feeds', **args)
+
+  def update_feed(self, client, id, **args):
+    return self.update_table(client, 'feeds', id, **args)
+
+  def delete_feed(self, client, id):
+    return self.delete_from_table(client, 'feeds', id)
+
+  @asyncio.coroutine
+  def set_rss_permissions(self, channel_id):
+    channel = client.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 = bot.highest_role(channel.server.me.roles)
+    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
+      yield from 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
+    yield from bot.overwrite_permissions(channel, channel.server.default_role, overwrite)
+
+  @asyncio.coroutine
+  def do_rss(self, feeds):
+    channel_id = str(feeds[0]['channel_id'])
+    channel = client.get_channel(channel_id)
+    if not channel:
+      log.warning("Can't get channel {} for feed.".format(channel_id))
+      return
+
+    all_entries = []
+    now = time.time()
+    for feed in feeds:
+      # Date can be trusted.
+      date = bot.parse_boolean(feed['date']) if 'date' in feed else True
+      # Include summary text.
+      summary = bot.parse_boolean(feed['summary']) if 'summary' in feed else False
+      url = feed['url']
+      d = feedparser.parse(url)
+      for entry in d.entries:
+        if date:
+          order = time.mktime(entry['published_parsed'])
+        else:
+          order = now
+          now += 0.001
+        all_entries.append({ 'order': order, 'date': date, 'summary': summary, 'url': feed['url'], 'link_json': feed['link_json'], 'entry': entry })
+    all_entries.sort(key = lambda entry: entry['order'])
+
+    limit = len(all_entries)
+    if not limit:
+      limit = bot.get('message_limit')
+
+    digests = []
+    logs = yield from client.logs_from(channel, limit = limit)
+    for message in logs:
+      digest = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
+      log.debug('Saw previously posted RSS with digest {}'.format(digest))
+      if digest not in digests:
+        digests.append(digest)
+
+    for feed in all_entries:
+      entry = feed['entry']
+      formatted = '*{}*\n'.format(entry['published']) if feed['date'] else ''
+      link = None
+      if 'links' in entry:
+        link = entry['links'][0]['href']
+      elif 'link_json' in feed:
+        try:
+          l = json.loads(feed['link_json'])
+          args = [entry[k] for k in l[1:]]
+          link = l[0].format(*args)
+        except ValueError:
+          log.error('Invalid link_json in feed {}'.format(feed['url']))
+        except:
+          logging.exception('do_rss:link_json')
+      if not link:
+        log.warning('No link for entry {}'.format(entry['title']))
+        continue
+      formatted += '**{}**\n{}'.format(entry['title'], link)
+      if feed['summary']:
+        formatted += '\n{}'.format(''.join(bs4.BeautifulSoup(entry['summary'], 'html.parser').findAll(text = True)))
+      formatted = formatted.strip()
+      digest = hashlib.sha224(formatted.encode('utf-8')).hexdigest()
+      if digest in digests:
+        log.debug("Already posted RSS with digest {}".format(digest))
+        continue
+      try:
+        if bot.get('dryrun'):
+          log.info('Dryrun: Not posting to {}: {}'.format(channel, formatted))
+        else:
+          log.info('Posting to {}: {}'.format(channel, formatted))
+          message = yield from bot.say(channel, formatted)
+          digest = hashlib.sha224(message.content.encode('utf-8')).hexdigest()
+          digests.append(digest)
+          log.debug('Posted RSS with digest {}'.format(digest))
+      except:
+        logging.exception("do_rss")
+
+  @asyncio.coroutine
+  def on_ready(self):
+    while True:
+      feeds = {}
+      for feed in list(self.get_feeds(client)):
+        if not bot.parse_boolean(feed['enabled']):
+          continue
+        feeds.setdefault(feed['channel_id'], [])
+        feeds[feed['channel_id']].append(feed)
+      for channel_id in feeds.keys():
+        yield from self.set_rss_permissions(channel_id)
+        yield from self.do_rss(feeds[channel_id])
+      yield from asyncio.sleep(rsstime.value)
+