A little bot to send updates from a private AoC leaderboard to a discord channel.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

bot.py 5.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import discord
  2. from discord.ext import tasks
  3. import os
  4. import sqlite3
  5. from collections import defaultdict
  6. from aiohttp import ClientSession
  7. import json
  8. client = discord.Client()
  9. conn = sqlite3.connect('data.db')
  10. star_names = { '1': 'silver', '2': 'gold' }
  11. aoc_session = os.getenv('AOC_SESSION')
  12. bot_token = os.getenv('AOC_BOT_TOKEN')
  13. @client.event
  14. async def on_ready():
  15. print('We have logged in as {0.user}'.format(client))
  16. @client.event
  17. async def on_message(message):
  18. if message.content.startswith("!setup"):
  19. leaderboard = int(message.content[7:])
  20. conn.execute('''
  21. insert into guild(id, channel, leaderboard, last_sync)
  22. values (?, ?, ?, ?) on conflict(id) do
  23. update set channel=excluded.channel, leaderboard=excluded.leaderboard''',
  24. (message.guild.id, message.channel.id, leaderboard, None))
  25. conn.commit()
  26. await message.channel.send(
  27. 'Updated {0.guild} to use channel {0.channel} and leaderboard {1}!'
  28. .format(message, leaderboard))
  29. def setup():
  30. '''
  31. Create all the necessary tables and all that.
  32. '''
  33. conn.execute('''
  34. CREATE TABLE IF NOT EXISTS
  35. guild(id int primary key, channel int, leaderboard int, last_sync text)
  36. ''')
  37. def member_stars(member):
  38. '''
  39. Find the stars attributed to a single member
  40. in the JSON response. Takes a JSON object
  41. from the 'members' dict.
  42. '''
  43. star_list = []
  44. for day in member['completion_day_level']:
  45. stars = member['completion_day_level'][day]
  46. for (star, name) in star_names.items():
  47. if star not in stars: continue
  48. star_list.append(
  49. (member['name'], day, name, stars[star]['get_star_ts']))
  50. return star_list
  51. def all_stars(json):
  52. '''
  53. Find all the stars in a given JSON response.
  54. The stars are not ordered in any way.
  55. '''
  56. return [star for m in json['members'].values() for star in member_stars(m)]
  57. def stars_by_day(stars):
  58. '''
  59. Groups stars by day, then by type (silver/gold).
  60. Helpful when trying to figure out if a given star
  61. was "early" (first/second/third) for a particular puzzle.
  62. '''
  63. by_day = defaultdict(lambda: defaultdict(lambda: []))
  64. for star in stars:
  65. by_day[star[1]][star[2]].append(star)
  66. return by_day
  67. def read_json(s):
  68. '''
  69. Try to parse a JSON response. If the argument
  70. is None (which it can be if we're pulling from a fresh database)
  71. fill it with just enough dummy enough to make the update
  72. algorithms work.
  73. '''
  74. if s is None: return {'members': {}}
  75. return json.loads(s)
  76. def detect_early_stars(stars):
  77. '''
  78. Detects stars for each puzzle that are 'first', 'second', and 'third'.
  79. This helps make the announcements a little more interesting!
  80. '''
  81. by_day = stars_by_day(stars)
  82. early_stars = []
  83. for (day, names) in by_day.items():
  84. for (name, stars) in names.items():
  85. stars.sort(key=lambda star: int(star[3]))
  86. early_stars += list(zip(['first', 'second', 'third'], stars))
  87. return early_stars
  88. def find_updates(old_json, new_json):
  89. '''
  90. Find interesting differences between the old JSON and the new JSON,
  91. both given as either strings or None.
  92. Interesting differences include member joins (for whom
  93. we do not print new stars, since they weren't on the learderboard
  94. when they won them), early stars (first/second/third) and other
  95. stars (anyone winning a star at any point).
  96. '''
  97. old_json = read_json(old_json)
  98. new_json = read_json(new_json)
  99. old_stars = all_stars(old_json)
  100. new_stars = all_stars(new_json)
  101. join = [new_json['members'][m]['name'] for m in new_json['members'] if m not in old_json['members']]
  102. early_stars = [ star for star in detect_early_stars(new_stars)
  103. if star[1] not in old_stars
  104. and star[1][0] not in join ]
  105. skip_stars = [early_star for (place, early_star) in early_stars]
  106. ann_stars = [ star for star in new_stars
  107. if star not in skip_stars
  108. and star not in old_stars
  109. and star[0] not in join ]
  110. return { 'join': join, 'early_stars': early_stars, 'ann_stars': ann_stars }
  111. async def send_updates(id, channel, diff):
  112. '''
  113. Send updates via the Discord client given a diff
  114. produced by `find_updates`.
  115. '''
  116. announcements = []
  117. for user in diff['join']:
  118. full_text = "User {0} joined the leaderboard!".format(user)
  119. announcements.append(full_text)
  120. for (place, (user, day, star, ts)) in diff['early_stars']:
  121. full_text = "{0} won the {1} {2} star from day {3}!".format(user, place, star, day)
  122. announcements.append(full_text)
  123. for (user, day, star, ts) in diff['ann_stars']:
  124. full_text = "{0} won the {1} star from day {2}!".format(user, star, day)
  125. announcements.append(full_text)
  126. if len(announcements) != 0:
  127. channel = await client.fetch_channel(channel)
  128. await channel.send("\n".join(announcements))
  129. @tasks.loop(minutes=1.0)
  130. async def update_aoc():
  131. url_string = "https://adventofcode.com/2020/leaderboard/private/view/{0}.json"
  132. cookies = { 'session': aoc_session }
  133. async with ClientSession(cookies=cookies) as session:
  134. for (id, channel, leaderboard, json) in conn.execute('select * from guild'):
  135. async with session.get(url_string.format(leaderboard)) as resp:
  136. new_json = await resp.text()
  137. diff = find_updates(json, new_json)
  138. await send_updates(id, channel, diff)
  139. conn.execute('update guild set last_sync=? where id=?', (new_json, id))
  140. conn.commit()
  141. setup()
  142. update_aoc.start()
  143. client.run(bot_token)