#!/usr/bin/env python3 import asyncio import discord import json import random import re import sys BLOCKS_FILE = 'blocks.json' def get_blocks(): try: with open(BLOCKS_FILE) as f: return {int(k): v for (k, v) in json.load(f).items()} except FileNotFoundError: return {} def add_block(requester, target): blocks = get_blocks() if requester not in blocks: blocks[requester] = [] if target not in blocks[requester]: blocks[requester].append(target) with open(BLOCKS_FILE, 'w') as f: json.dump(blocks, f) def remove_block(requester, target): blocks = get_blocks() if requester not in blocks: return while target in blocks[requester]: blocks[requester].remove(target) if len(blocks[requester]) == 0: del blocks[requester] with open(BLOCKS_FILE, 'w') as f: json.dump(blocks, f) def matchings(l): count = len(l) if count == 0: return [] l = l.copy() random.shuffle(l) ids = set(u.id for u in l) blocks = get_blocks() block_pairs = set() for r in blocks: if r in ids: for t in blocks[r]: if t in ids: block_pairs.add((min(r,t), max(r,t))) disjoint_pairs = [] while True: paired = set(u for pair in disjoint_pairs for u in pair) remaining = set(p for p in block_pairs if p[0] not in paired and p[1] not in paired) if len(remaining) == 0: break disjoint_pairs.append(remaining.pop()) circle = [None]*(count - 1) center = None for i in range(count // 2): if len(disjoint_pairs) != 0: p = disjoint_pairs.pop() [u0, u1] = [u for u in l if u.id in p] l = [u for u in l if u.id not in p] else: u0 = l.pop() u1 = l.pop() circle[i] = u0 if i != count // 2 - 1: circle[-(i+1)] = u1 else: center = u1 result = [] for i in range(len(circle)): matching = [(center, circle[i])] for j in range(1, (len(circle)-1)//2 + 1): matching.append((circle[(i-j)%len(circle)], circle[(i+j)%len(circle)])) result.append(matching) random.shuffle(result) for i in range(len(result)): random.shuffle(result[i]) for j in range(len(result[i])): if random.randrange(2) == 0: result[i][j] = (result[i][j][1], result[i][j][0]) result = [matching for matching in result if all((min(match[0].id,match[1].id), max(match[0].id,match[1].id)) not in block_pairs for match in matching)] return result async def delete_if_possible(channels): for c in channels: try: await c.delete() except discord.DiscordException: pass async def countdown(channel): await asyncio.sleep(20) await channel.send('10', tts=True) await asyncio.sleep(5) await channel.send('5', tts=True) await asyncio.sleep(5) with open('config.json') as f: config = json.load(f) client = discord.Client() async def handle_guild_message(message): if not message.content.startswith('!waggle'): return if not message.author.guild_permissions.administrator: await message.channel.send('only administrators can start pollination!') return argv = message.content.split() if len(argv) > 1: try: rounds = int(message.content.split()[1]) except ValueError: await message.channel.send("usage: `!waggle 6` for at most 6 rounds of pollination, `!waggle` for as many rounds as possible until everyone's met everyone else") return else: rounds = None voice_state = message.author.voice if voice_state is None or voice_state.channel is None: await message.channel.send('you need to be in the main gathering voice channel to start pollination') return main_voice_channel = voice_state.channel category = message.channel.category if category is None: await message.channel.send("please re-send this message in a channel in the category where you'd like me to create voice channels") return participants = main_voice_channel.members.copy() if len(participants) % 2 != 0: participants.remove(message.author) await message.channel.send('leaving out {} so we have an even number of participants'.format(message.author.mention)) mention_all = ' '.join(u.mention for u in participants) schedule = matchings(participants) if rounds: schedule = schedule[:rounds] if len(schedule) == 0: await message.channel.send("there aren't enough people for pollination right now :(") return pollination_channels = [] channel_names = config['channel_names'].copy() exception_count = 0 while len(pollination_channels) < len(participants)//2: if len(channel_names) > 0: name = channel_names.pop(0) else: name = 'pollination-{}'.format(random.randrange(10000)) try: pollination_channels.append(await category.create_voice_channel(name)) exception_count = 0 except discord.Forbidden: await message.channel.send("looks like I'm not allowed to create voice channels :(") await delete_if_possible(pollination_channels) return except discord.DiscordException as e: exception_count += 1 if exception_count > 20: await message.channel.send("I'm trying to create voice channels, but something's wrong: {}".format(e)) await delete_if_possible(pollination_channels) return continue await message.channel.send("{} Welcome to pollination! You'll be randomly paired up, moved into separate voice channels, spend some time chatting, and then move to a new channel and meet somebody else. Each round will last 5 minutes, and there'll be {} rounds total. I'll announce when your time is nearly up so you can wrap up your conversations, and I'll automatically move you to a different voice channel at the start of each new round. If you keep this text channel open, you'll get audible countdowns via text-to-speech at the end of each round. **Feel free to leave at any time if you need to. You won't get moved into new voice channels if you disconnect from voice.** If you find yourself alone in a channel, the person you were paired with for that round may have left; just relax and take a break for 5 minutes. Have fun!".format(mention_all, len(schedule))) for matching in schedule: announcement = 'Next round starting in 30 seconds:\n' announcement += '\n'.join('{0} and {1} in {2}'.format(a.mention, b.mention, c.name) for ((a, b), c) in zip(matching, pollination_channels)) await message.channel.send(announcement) await countdown(message.channel) for ((a, b), c) in zip(matching, pollination_channels): try: await a.move_to(c) except discord.DiscordException as e: print('failed to move participant {}: {}'.format(a, e), file=sys.stderr) try: await b.move_to(c) except discord.DiscordException as e: print('failed to move participant {}: {}'.format(b, e), file=sys.stderr) await asyncio.sleep(60*3) await message.channel.send('Round ending in 2 minutes {}'.format(mention_all)) await asyncio.sleep(60) await message.channel.send('Round ending in 1 minute {}'.format(mention_all)) await asyncio.sleep(30) await message.channel.send('Returning to main channel in 30 seconds {}'.format(mention_all)) await countdown(message.channel) for u in participants: try: await u.move_to(main_voice_channel) except discord.DiscordException as e: print('failed to move participant {}: {}'.format(u, e), file=sys.stderr) await delete_if_possible(pollination_channels) async def handle_dm(message): requester = message.author.id match = re.search('@([^#]+)#([0-9a-f]+)', message.content) if match: handle = match.group(0) name = match.group(1) discriminator = match.group(2) matching_users = [u for u in client.users if u.name == name and u.discriminator == discriminator] if len(matching_users) == 0: await message.channel.send("sorry, I can't find user {}, it looks like they're not in any of the discords I'm in".format(handle)) else: target = matching_users[0].id if target in get_blocks().get(requester, []): await message.channel.send("removing {} from your list of blocked users for pollination".format(handle)) remove_block(requester, target) else: await message.channel.send("adding {} to your list of blocked users for pollination".format(handle)) add_block(requester, target) blocks = get_blocks().get(requester, []) if len(blocks) == 0: await message.channel.send("you currently don't have anyone blocked for pollination") else: await message.channel.send("your current list of blocked users for pollination is:\n{}".format('\n'.join('@{}#{}'.format(u.name, u.discriminator) for u in [client.get_user(uid) for uid in blocks] if u is not None))) await message.channel.send("to block or unblock a user, DM me their handle, like @creep#0000. to see this message and your list of blocked users, DM me something random. buzz buzz!") @client.event async def on_ready(): print('logged in as {0.user}'.format(client), file=sys.stderr) @client.event async def on_message(message): if message.author == client.user: return if isinstance(message.channel, discord.TextChannel): await handle_guild_message(message) elif isinstance(message.channel, discord.DMChannel): await handle_dm(message) client.run(config['token'])