Feeds feature parity.
authorCMDR furrycat <elite@furrycat.net>
Wed, 26 Oct 2016 10:12:05 +0000 (11:12 +0100)
committerCMDR furrycat <elite@furrycat.net>
Wed, 26 Oct 2016 10:18:52 +0000 (11:18 +0100)
app.py
plugins/__init__.py
plugins/feeds.py

diff --git a/app.py b/app.py
index 412f556..19f8c0d 100644 (file)
--- a/app.py
+++ b/app.py
@@ -293,12 +293,6 @@ def show_help(message, *args):
     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.',
@@ -365,6 +359,9 @@ def show_help(message, *args):
     yield from fn(message, 'help', 'help {}'.format(command), *args)
     return
   else:
+    result = yield from plugins.handle_help(message, *args)
+    if result:
+      return
     lines = ['*shrugs*']
   yield from bot.say_many(message.channel, lines)
 
@@ -1417,401 +1414,6 @@ def do_announcements():
     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:
@@ -1834,11 +1436,6 @@ modules = {
     'args': [],
     'commands': ['announce', 'announcement']
   },
-  'feeds': {
-    'fn': manage_feeds,
-    'args': [],
-    'commands': ['news', 'feed']
-  },
   'any': {
     'fn': do_commands,
     'args': [True],
@@ -2038,7 +1635,9 @@ def on_message(message):
   if m is None:
     return
   command = m.group(1)
-  yield from process_module(message, command, raw)
+  result = yield from plugins.handle_command(message, command, raw)
+  if not result:
+    yield from process_module(message, command, raw)
 
 @client.event
 @asyncio.coroutine
index 5261b83..b4fdf50 100644 (file)
@@ -1,10 +1,17 @@
+import asyncio
 import glob
 import importlib
 import logging
 import os
+from enum import Enum
 
 log = logging.getLogger('plugins')
 
+class PluginCommand(Enum):
+  handled = 1
+  ignored = 2
+  exclusive = 4
+
 class Plugins(object):
   def __init__(self):
     self.loaded = {}
@@ -29,9 +36,36 @@ class Plugins(object):
       except:
         logging.exception('refresh')
 
+  def plugins(self):
+    return self.loaded.keys()
+
+  def get_plugin(self, name):
+    if name not in self.loaded:
+      return None
+    return self.loaded[name]['plugin']
+
+  def has_method(self, name, method):
+    if name not in self.loaded:
+      return None
+    return hasattr(self.get_plugin(name), method)
+
+  def call_method(self, name, method, *args):
+    if not self.has_method(name, method):
+      log.error('Plugin {} has no method {}!'.format(name, method))
+      return None
+    return getattr(self.get_plugin(name), method)(*args)
+
+  @asyncio.coroutine
+  def call_coroutine(self, name, coroutine, *args):
+    if not self.has_method(name, coroutine):
+      log.error('Plugin {} has no coroutine {}!'.format(name, coroutine))
+      return None
+    result = yield from getattr(self.get_plugin(name), coroutine)(*args)
+    return result
+
   def log_level(self, level):
     log.setLevel(level)
-    for name in self.loaded.keys():
+    for name in self.plugins():
       logging.getLogger(name).setLevel(level)
 
   def set_client(self, client):
@@ -39,10 +73,46 @@ class Plugins(object):
       data['module'].client = client
 
   def all(self):
-    for name, data in self.loaded.items():
-      yield data['plugin']
+    for name in self.plugins():
+      yield self.get_plugin(name)
 
   def on_ready(self):
-    for name, data in self.loaded.items():
-      if hasattr(data['module'], 'on_ready'):
-        asyncio.async(data['module'].on_ready())
+    for name in self.plugins():
+      if self.has_method(name, 'on_ready'):
+        asyncio.async(self.call_coroutine(name, 'on_ready'))
+
+  # Plugin can return True to force skipping other handlers.
+  @asyncio.coroutine
+  def handle_command(self, message, command, raw):
+    for name in self.plugins():
+      if not self.has_method(name, 'handle_command'):
+        log.debug("Plugin {} doesn't handle {} command.".format(name, command))
+        continue
+      if self.has_method(name, 'valid_commands'):
+        if command not in self.call_method(name, 'valid_commands'):
+          log.debug("Plugin {} doesn't handle {} command.".format(name, command))
+          continue
+      result = yield from self.call_coroutine(name, 'handle_command', message, command, raw)
+      if result is PluginCommand.ignored:
+        log.debug('Plugin {} ignored command {}.'.format(name, command))
+        continue
+      elif result is PluginCommand.exclusive:
+        log.info('Plugin {} handled command {} exclusively.'.format(name, command))
+        return True
+      elif result is PluginCommand.handled:
+        log.debug('Plugin {} handled command {}.'.format(name, command))
+        continue
+
+  @asyncio.coroutine
+  def handle_help(self, message, command, *args):
+    for name in self.plugins():
+      if not self.has_method(name, 'handle_help'):
+        log.debug("Plugin {} doesn't handle help".format(name, command))
+        continue
+      if self.has_method(name, 'valid_commands'):
+        if command not in self.call_method(name, 'valid_commands'):
+          log.debug("Plugin {} doesn't handle help for {} command.".format(name, command))
+          continue
+      result = yield from self.call_coroutine(name, 'handle_help', message, command, *args)
+      return True
+    return False
index 6ad9755..d9c8f50 100644 (file)
@@ -6,9 +6,12 @@ import hashlib
 import json
 import logging
 import multiprocessing
+import re
+import shlex
 import time
 
 from db import DBConnection
+from plugins import PluginCommand
 import bot
 
 rsstime = multiprocessing.Value('i', 600)
@@ -164,3 +167,405 @@ class Feeds(DBConnection):
         yield from self.do_rss(feeds[channel_id])
       yield from asyncio.sleep(rsstime.value)
 
+  def valid_commands(self):
+    return ['news', 'feed']
+
+  @asyncio.coroutine
+  def handle_command(self, message, command, raw):
+    if command not in self.valid_commands():
+      return PluginCommand.ignored
+    yield from self.manage_feeds(message, shlex.split(raw)[1:])
+    return PluginCommand.exclusive
+
+  @asyncio.coroutine
+  def handle_help(self, message, command, *args):
+    yield from self.help_feeds(message, *args)
+
+  @asyncio.coroutine
+  def manage_feeds(self, message, args):
+    if not len(args):
+      yield from self.list_feeds(message)
+      return
+    command = args[0].lower()
+    if len(args) == 1:
+      if command == 'list':
+        yield from self.list_feeds(message)
+        return
+      elif command == 'help':
+        yield from self.help_feeds(message)
+        return
+      else:
+        yield from bot.say(message.channel, 'yelp!')
+        return
+
+    if command == 'show':
+      yield from self.show_feed(message, args[1])
+    elif command == 'help':
+      yield from self.help_feeds(message, args[1])
+    elif command == 'delete':
+      yield from self.delete_existing_feed(message, args[1])
+    elif command == 'pause':
+      yield from self.schedule_feed(message, args[1], pause = True)
+    elif command == 'resume':
+      yield from self.schedule_feed(message, args[1], pause = False)
+    elif command == 'create':
+      yield from self.create_new_feed(message, args)
+    elif command == 'edit':
+      yield from self.edit_existing_feed(message, args)
+
+  @asyncio.coroutine
+  def can_manage_feeds(self, author, channel, command, **args):
+    # Anyone can list feeds.
+    if command == 'list':
+      log.debug('Anyone can list feeds.')
+      return True
+
+    if 'id' in args:
+      feed = self.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_member = server.get_member(client.user.id)
+        member_role = bot.highest_role(member.roles)
+        bot_role = bot.highest_role(bot_member.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 list_feeds(self, message):
+    result = yield from self.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 self.get_all_feeds(client, servers):
+      text = '{} **{}** url {} in <#{}>'.format(feed['description'], 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(self, message, id):
+    feed = yield from self.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(self, message, args, editing = False):
+    parsed = {}
+    if editing:
+      command = 'edit'
+    else:
+      command = 'create'
+
+    log.debug('feed {} params {}'.format(command, *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 == 'in':
+        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_new_feed(self, message, *args):
+    # feed create [params]: <text>
+    create = yield from self.parse_feed(message, *args)
+    if create is None:
+      return
+
+    feed = yield from self.can_manage_feeds(message.author, message.channel, 'create', create = create)
+    if not feed:
+      return
+
+    id = self.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_existing_feed(self, message, *args):
+    update = yield from self.parse_feed(message, *args, editing = True)
+    if update is None:
+      return
+
+    id = update['id']
+    del(update['id'])
+    feed = yield from self.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 self.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_existing_feed(self, message, id):
+    feed = yield from self.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 self.delete_feed(client, id):
+        yield from bot.say(message.channel, 'purr')
+      else:
+        yield from bot.say(message.channel, 'yelp!')
+
+  @asyncio.coroutine
+  def schedule_feed(self, message, id, **args):
+    feed = yield from self.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 self.update_feed(client, id, **update):
+        yield from bot.say(message.channel, 'purr')
+      else:
+        yield from bot.say(message.channel, 'yelp!')
+
+  @asyncio.coroutine
+  def help_feeds(self, message, command = None):
+    log.info('help {}'.format(command))
+    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)