Added commands to manage news feeds.
authorCMDR furrycat <elite@furrycat.net>
Sun, 23 Oct 2016 11:39:39 +0000 (12:39 +0100)
committerCMDR furrycat <elite@furrycat.net>
Sun, 23 Oct 2016 11:39:39 +0000 (12:39 +0100)
bot.py
db.py

diff --git a/bot.py b/bot.py
index 847769c..c187314 100755 (executable)
--- a/bot.py
+++ b/bot.py
@@ -457,6 +457,12 @@ 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.',
@@ -1272,6 +1278,17 @@ def announcement_key(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.
@@ -1510,7 +1527,6 @@ def edit_announcement(message, raw):
     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)
@@ -1741,6 +1757,397 @@ 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 = 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 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:
@@ -1763,6 +2170,11 @@ modules = {
     'args': [],
     'commands': ['announce', 'announcement']
   },
+  'feeds': {
+    'fn': manage_feeds,
+    'args': [],
+    'commands': ['news', 'feed']
+  },
   'any': {
     'fn': do_commands,
     'args': [True],
diff --git a/db.py b/db.py
index 3572a47..94a7530 100644 (file)
--- a/db.py
+++ b/db.py
@@ -189,6 +189,9 @@ class DBConnection(object):
   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)