def test_emitter_sequential(): emitter = Emitter() class TestEvent(object): pass def handler_contained(event): time.sleep(random.randint(1, 100) / 100) event.value += 1 event.order.append(event.value) if event.value == 3: event.result.set() event = TestEvent() event.order = [] event.value = 0 event.result = AsyncResult() emitter.on('test', handler_contained, priority=Priority.SEQUENTIAL) emitter.emit('test', event) emitter.emit('test', event) emitter.emit('test', event) event.result.wait() assert event.order == [1, 2, 3] assert event.value == 3
class Websocket(LoggingClass, websocket.WebSocketApp): """ A utility class which wraps the functionality of :class:`websocket.WebSocketApp` changing its behavior to better conform with standard style across disco. The major difference comes with the move from callback functions, to all events being piped into a single emitter. """ def __init__(self, *args, **kwargs): LoggingClass.__init__(self) websocket.WebSocketApp.__init__(self, *args, **kwargs) self.emitter = Emitter(spawn_each=True) # Hack to get events to emit for var in six.iterkeys(self.__dict__): if not var.startswith('on_'): continue setattr(self, var, var) def _get_close_args(self, data): if data and len(data) >= 2: code = 256 * six.byte2int(data[0:1]) + six.byte2int(data[1:2]) reason = data[2:].decode('utf-8') return [code, reason] return [None, None] def _callback(self, callback, *args): if not callback: return self.emitter.emit(callback, *args)
def test_emitter_before(): emitter = Emitter() class TestEvent(object): pass def handler_before(event): time.sleep(1) event.value = 1 def handler_default(event): event.result.set(event.value) event = TestEvent() event.result = AsyncResult() emitter.on('test', handler_before, priority=Priority.BEFORE) emitter.on('test', handler_default) emitter.emit('test', event) assert event.result.wait() == 1
class VoiceClient(LoggingClass): def __init__(self, channel, encoder=None): super(VoiceClient, self).__init__() if not channel.is_voice: raise ValueError( 'Cannot spawn a VoiceClient for a non-voice channel') self.channel = channel self.client = self.channel.client self.encoder = encoder or JSONEncoder # Bind to some WS packets self.packets = Emitter(gevent.spawn) self.packets.on(VoiceOPCode.READY, self.on_voice_ready) self.packets.on(VoiceOPCode.SESSION_DESCRIPTION, self.on_voice_sdp) # State + state change emitter self.state = VoiceState.DISCONNECTED self.state_emitter = Emitter(gevent.spawn) # Connection metadata self.token = None self.endpoint = None self.ssrc = None self.port = None self.secret_box = None self.udp = None # Voice data state self.sequence = 0 self.timestamp = 0 self.update_listener = None # Websocket connection self.ws = None self.heartbeat_task = None def __repr__(self): return u'<VoiceClient {}>'.format(self.channel) def set_state(self, state): self.log.debug('[%s] state %s -> %s', self, self.state, state) prev_state = self.state self.state = state self.state_emitter.emit(state, prev_state) def heartbeat(self, interval): while True: self.send(VoiceOPCode.HEARTBEAT, time.time() * 1000) gevent.sleep(interval / 1000) def set_speaking(self, value): self.send(VoiceOPCode.SPEAKING, { 'speaking': value, 'delay': 0, }) def send(self, op, data): self.log.debug('[%s] sending OP %s (data = %s)', self, op, data) self.ws.send(self.encoder.encode({ 'op': op.value, 'd': data, }), self.encoder.OPCODE) def on_voice_ready(self, data): self.log.info( '[%s] Recived Voice READY payload, attempting to negotiate voice connection w/ remote', self) self.set_state(VoiceState.CONNECTING) self.ssrc = data['ssrc'] self.port = data['port'] self.heartbeat_task = gevent.spawn(self.heartbeat, data['heartbeat_interval']) self.log.debug('[%s] Attempting IP discovery over UDP to %s:%s', self, self.endpoint, self.port) self.udp = UDPVoiceClient(self) ip, port = self.udp.connect(self.endpoint, self.port) if not ip: self.log.error( 'Failed to discover our IP, perhaps a NAT or firewall is f*****g us' ) self.disconnect() return self.log.debug( '[%s] IP discovery completed (ip = %s, port = %s), sending SELECT_PROTOCOL', self, ip, port) self.send( VoiceOPCode.SELECT_PROTOCOL, { 'protocol': 'udp', 'data': { 'port': port, 'address': ip, 'mode': 'xsalsa20_poly1305' } }) def on_voice_sdp(self, sdp): self.log.info( '[%s] Recieved session description, connection completed', self) # Create a secret box for encryption/decryption self.secret_box = nacl.secret.SecretBox( bytes(bytearray(sdp['secret_key']))) # Toggle speaking state so clients learn of our SSRC self.set_speaking(True) self.set_speaking(False) gevent.sleep(0.25) self.set_state(VoiceState.CONNECTED) def on_voice_server_update(self, data): if self.channel.guild_id != data.guild_id or not data.token: return if self.token and self.token != data.token: return self.log.info('[%s] Recieved VOICE_SERVER_UPDATE (state = %s)', self, self.state) self.token = data.token self.set_state(VoiceState.AUTHENTICATING) self.endpoint = data.endpoint.split(':', 1)[0] self.ws = Websocket('wss://' + self.endpoint) self.ws.emitter.on('on_open', self.on_open) self.ws.emitter.on('on_error', self.on_error) self.ws.emitter.on('on_close', self.on_close) self.ws.emitter.on('on_message', self.on_message) self.ws.run_forever() def on_message(self, msg): try: data = self.encoder.decode(msg) self.packets.emit(VoiceOPCode[data['op']], data['d']) except: self.log.exception('Failed to parse voice gateway message: ') def on_error(self, err): # TODO: raise an exception here self.log.error('[%s] Voice websocket error: %s', self, err) def on_open(self): self.send( VoiceOPCode.IDENTIFY, { 'server_id': self.channel.guild_id, 'user_id': self.client.state.me.id, 'session_id': self.client.gw.session_id, 'token': self.token }) def on_close(self, code, error): self.log.warning('[%s] Voice websocket disconnected (%s, %s)', self, code, error) if self.state == VoiceState.CONNECTED: self.log.info('Attempting voice reconnection') self.connect() def connect(self, timeout=5, mute=False, deaf=False): self.log.debug('[%s] Attempting connection', self) self.set_state(VoiceState.AWAITING_ENDPOINT) self.update_listener = self.client.events.on( 'VoiceServerUpdate', self.on_voice_server_update) self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': mute, 'self_deaf': deaf, 'guild_id': int(self.channel.guild_id), 'channel_id': int(self.channel.id), }) if not self.state_emitter.once(VoiceState.CONNECTED, timeout=timeout): raise VoiceException('Failed to connect to voice', self) def disconnect(self): self.log.debug('[%s] disconnect called', self) self.set_state(VoiceState.DISCONNECTED) if self.heartbeat_task: self.heartbeat_task.kill() self.heartbeat_task = None if self.ws and self.ws.sock.connected: self.ws.close() if self.udp and self.udp.connected: self.udp.disconnect() self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': False, 'self_deaf': False, 'guild_id': int(self.channel.guild_id), 'channel_id': None, }) def send_frame(self, *args, **kwargs): self.udp.send_frame(*args, **kwargs)
class CorePlugin(Plugin): def load(self, ctx): init_db(ENV) self.startup = ctx.get('startup', datetime.utcnow()) self.guilds = ctx.get('guilds', {}) self.emitter = Emitter(gevent.spawn) super(CorePlugin, self).load(ctx) # Overwrite the main bot instances plugin loader so we can magicfy events self.bot.add_plugin = self.our_add_plugin if ENV != 'prod': self.spawn(self.wait_for_plugin_changes) self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) def spawn_wait_for_actions(self, *args, **kwargs): self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) self._wait_for_actions_greenlet.link_exception( self.spawn_wait_for_actions) def our_add_plugin(self, cls, *args, **kwargs): if getattr(cls, 'global_plugin', False): Bot.add_plugin(self.bot, cls, *args, **kwargs) return inst = cls(self.bot, None) inst.register_trigger('command', 'pre', functools.partial(self.on_pre, inst)) inst.register_trigger('listener', 'pre', functools.partial(self.on_pre, inst)) Bot.add_plugin(self.bot, inst, *args, **kwargs) def wait_for_plugin_changes(self): import gevent_inotifyx as inotify fd = inotify.init() inotify.add_watch(fd, 'rowboat/plugins/', inotify.IN_MODIFY) while True: events = inotify.get_events(fd) for event in events: # Can't reload core.py sadly if event.name.startswith('core.py'): continue plugin_name = '{}Plugin'.format( event.name.split('.', 1)[0].title()) plugin = next((v for k, v in self.bot.plugins.items() if k.lower() == plugin_name.lower()), None) if plugin: self.log.info('Detected change in %s, reloading...', plugin_name) try: plugin.reload() except Exception: self.log.exception('Failed to reload: ') def wait_for_actions(self): ps = rdb.pubsub() ps.subscribe('actions') for item in ps.listen(): if item['type'] != 'message': continue data = json.loads(item['data']) if data['type'] == 'GUILD_UPDATE' and data['id'] in self.guilds: with self.send_control_message() as embed: embed.title = u'Reloaded config for {}'.format( self.guilds[data['id']].name) self.log.info(u'Reloading guild %s', self.guilds[data['id']].name) # Refresh config, mostly to validate try: config = self.guilds[data['id']].get_config(refresh=True) # Reload the guild entirely self.guilds[data['id']] = Guild.with_id(data['id']) # Update guild access self.update_rowboat_guild_access() # Finally, emit the event self.emitter.emit('GUILD_CONFIG_UPDATE', self.guilds[data['id']], config) except: self.log.exception(u'Failed to reload config for guild %s', self.guilds[data['id']].name) continue elif data['type'] == 'RESTART': self.log.info('Restart requested, signaling parent') os.kill(os.getppid(), signal.SIGUSR1) def unload(self, ctx): ctx['guilds'] = self.guilds ctx['startup'] = self.startup super(CorePlugin, self).unload(ctx) def update_rowboat_guild_access(self): # if ROWBOAT_GUILD_ID not in self.state.guilds or ENV != 'prod': # return rb_guild = self.state.guilds.get(ROWBOAT_GUILD_ID) if not rb_guild: return self.log.info('Updating rowboat guild access') guilds = Guild.select(Guild.guild_id, Guild.config).where( (Guild.enabled == 1)) users_who_should_have_access = set() for guild in guilds: if 'web' not in guild.config: continue for user_id in guild.config['web'].keys(): try: users_who_should_have_access.add(int(user_id)) except: self.log.warning('Guild %s has invalid user ACLs: %s', guild.guild_id, guild.config['web']) # TODO: sharding users_who_have_access = { i.id for i in rb_guild.members.values() if ROWBOAT_USER_ROLE_ID in i.roles } remove_access = set(users_who_have_access) - set( users_who_should_have_access) add_access = set(users_who_should_have_access) - set( users_who_have_access) for user_id in remove_access: member = rb_guild.members.get(user_id) if not member: continue member.remove_role(ROWBOAT_USER_ROLE_ID) for user_id in add_access: member = rb_guild.members.get(user_id) if not member: continue member.add_role(ROWBOAT_USER_ROLE_ID) def on_pre(self, plugin, func, event, args, kwargs): """ This function handles dynamically dispatching and modifying events based on a specific guilds configuration. It is called before any handler of either commands or listeners. """ if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None if guild_id not in self.guilds: if isinstance(event, CommandEvent): if event.command.metadata.get('global_', False): return event elif hasattr(func, 'subscriptions'): if func.subscriptions[0].metadata.get('global_', False): return event return if hasattr(plugin, 'WHITELIST_FLAG'): if not int( plugin.WHITELIST_FLAG) in self.guilds[guild_id].whitelist: return event.base_config = self.guilds[guild_id].get_config() if not event.base_config: return plugin_name = plugin.name.lower().replace('plugin', '') if not getattr(event.base_config.plugins, plugin_name, None): return self._attach_local_event_data(event, plugin_name, guild_id) return event def get_config(self, guild_id, *args, **kwargs): # Externally Used return self.guilds[guild_id].get_config(*args, **kwargs) def get_guild(self, guild_id): # Externally Used return self.guilds[guild_id] def _attach_local_event_data(self, event, plugin_name, guild_id): if not hasattr(event, 'config'): event.config = LocalProxy() if not hasattr(event, 'rowboat_guild'): event.rowboat_guild = LocalProxy() event.config.set(getattr(event.base_config.plugins, plugin_name)) event.rowboat_guild.set(self.guilds[guild_id]) @Plugin.schedule(290, init=False) def update_guild_bans(self): to_update = [ guild for guild in Guild.select().where(( Guild.last_ban_sync < (datetime.utcnow() - timedelta(days=1))) | (Guild.last_ban_sync >> None)) if guild.guild_id in self.client.state.guilds ] # Update 10 at a time for guild in to_update[:10]: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) @Plugin.listen('GuildUpdate') def on_guild_update(self, event): self.log.info('Got guild update for guild %s (%s)', event.guild.id, event.guild.channels) @Plugin.listen('GuildBanAdd') def on_guild_ban_add(self, event): GuildBan.ensure(self.client.state.guilds.get(event.guild_id), event.user) @Plugin.listen('GuildBanRemove') def on_guild_ban_remove(self, event): GuildBan.delete().where((GuildBan.user_id == event.user.id) & (GuildBan.guild_id == event.guild_id)) @contextlib.contextmanager def send_control_message(self): embed = MessageEmbed() embed.set_footer(text='Rowboat {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_CONTROL_CHANNEL, embed=embed) except: self.log.exception('Failed to send control message:') return @Plugin.listen('Resumed') def on_resumed(self, event): Notification.dispatch( Notification.Types.RESUME, trace=event.trace, env=ENV, ) with self.send_control_message() as embed: embed.title = 'Resumed' embed.color = 0xffb347 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) embed.add_field(name='Replayed Events', value=str(self.client.gw.replayed_events)) @Plugin.listen('Ready', priority=Priority.BEFORE) def on_ready(self, event): reconnects = self.client.gw.reconnects self.log.info('Started session %s', event.session_id) Notification.dispatch( Notification.Types.CONNECT, trace=event.trace, env=ENV, ) with self.send_control_message() as embed: if reconnects: embed.title = 'Reconnected' embed.color = 0xffb347 else: embed.title = 'Connected' embed.color = 0x77dd77 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) @Plugin.listen('GuildCreate', priority=Priority.BEFORE, conditional=lambda e: not e.created) def on_guild_create(self, event): try: guild = Guild.with_id(event.id) except Guild.DoesNotExist: # If the guild is not awaiting setup, leave it now if not rdb.sismember(GUILDS_WAITING_SETUP_KEY, str( event.id)) and event.id != ROWBOAT_GUILD_ID: self.log.warning( 'Leaving guild %s (%s), not within setup list', event.id, event.name) event.guild.leave() return if not guild.enabled: return config = guild.get_config() if not config: return # Ensure we're updated self.log.info('Syncing Guild and Members in: %s (%s)', event.guild.name, event.guild.id) guild.sync(event.guild) event.guild.sync() self.guilds[event.id] = guild if config.nickname: def set_nickname(): m = event.members.select_one(id=self.state.me.id) if m and m.nick != config.nickname: try: m.set_nickname(config.nickname) except APIException as e: self.log.warning( 'Failed to set nickname for guild %s (%s)', event.guild, e.content) self.spawn_later(5, set_nickname) def get_level(self, guild, user): config = (guild.id in self.guilds and self.guilds.get(guild.id).get_config()) user_level = 0 if config: member = guild.get_member(user) if not member: return user_level for oid in member.roles: if oid in config.levels and config.levels[oid] > user_level: user_level = config.levels[oid] # User ID overrides should override all others if member.id in config.levels: user_level = config.levels[member.id] return user_level @Plugin.listen('MessageCreate') def on_message_create(self, event): """ This monstrosity of a function handles the parsing and dispatching of commands. """ # Ignore messages sent by bots if event.message.author.bot: return # If this is message for a guild, grab the guild object if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None guild = self.guilds.get(event.guild.id) if guild_id else None config = guild and guild.get_config() # If the guild has configuration, use that (otherwise use defaults) if config and config.commands: commands = list( self.bot.get_commands_for_message(config.commands.mention, {}, config.commands.prefix, event.message)) elif guild_id: # Otherwise, default to requiring mentions commands = list( self.bot.get_commands_for_message(True, {}, '', event.message)) else: if ENV != 'prod': if not event.message.content.startswith(ENV + '!'): return event.message.content = event.message.content[len(ENV) + 1:] # DM's just use the commands (no prefix/mention) commands = list( self.bot.get_commands_for_message(False, {}, '', event.message)) # If we didn't find any matching commands, return if not len(commands): return event.user_level = self.get_level(event.guild, event.author) if event.guild else 0 # Grab whether this user is a global admin # TODO: cache this global_admin = rdb.sismember('global_admins', event.author.id) # Iterate over commands and find a match for command, match in commands: if command.level == -1 and not global_admin: continue level = command.level if guild and not config and command.triggers[0] != 'setup': continue elif config and config.commands and command.plugin != self: overrides = {} for obj in config.commands.get_command_override(command): overrides.update(obj) if overrides.get('disabled'): continue level = overrides.get('level', level) if not global_admin and event.user_level < level: continue with timed('rowboat.command.duration', tags={ 'plugin': command.plugin.name, 'command': command.name }): try: command_event = CommandEvent(command, event.message, match) command_event.user_level = event.user_level command.plugin.execute(command_event) except CommandResponse as e: event.reply(e.response) except: tracked = Command.track(event, command, exception=True) self.log.exception('Command error:') with self.send_control_message() as embed: embed.title = u'Command Error: {}'.format(command.name) embed.color = 0xff6961 embed.add_field(name='Author', value='({}) `{}`'.format( event.author, event.author.id), inline=True) embed.add_field(name='Channel', value='({}) `{}`'.format( event.channel.name, event.channel.id), inline=True) embed.description = '```{}```'.format(u'\n'.join( tracked.traceback.split('\n')[-8:])) return event.reply( '<:{}> something went wrong, perhaps try again later'. format(RED_TICK_EMOJI)) Command.track(event, command) # Dispatch the command used modlog event if config: modlog_config = getattr(config.plugins, 'modlog', None) if not modlog_config: return self._attach_local_event_data(event, 'modlog', event.guild.id) plugin = self.bot.plugins.get('ModLogPlugin') if plugin: plugin.log_action(Actions.COMMAND_USED, event) return @Plugin.command('setup') def command_setup(self, event): if not event.guild: return event.msg.reply( ':warning: this command can only be used in servers') # Make sure we're not already setup if event.guild.id in self.guilds: return event.msg.reply(':warning: this server is already setup') global_admin = rdb.sismember('global_admins', event.author.id) # Make sure this is the owner of the server if not global_admin: if not event.guild.owner_id == event.author.id: return event.msg.reply( ':warning: only the server owner can setup rowboat') # Make sure we have admin perms m = event.guild.members.select_one(id=self.state.me.id) if not m.permissions.administrator and not global_admin: return event.msg.reply( ':warning: bot must have the Administrator permission') guild = Guild.setup(event.guild) rdb.srem(GUILDS_WAITING_SETUP_KEY, str(event.guild.id)) self.guilds[event.guild.id] = guild event.msg.reply(':ok_hand: successfully loaded configuration') @Plugin.command('nuke', '<user:snowflake> <reason:str...>', level=-1) def nuke(self, event, user, reason): contents = [] for gid, guild in self.guilds.items(): guild = self.state.guilds[gid] perms = guild.get_permissions(self.state.me) if not perms.ban_members and not perms.administrator: contents.append(u':x: {} - No Permissions'.format(guild.name)) continue try: Infraction.ban(self.bot.plugins.get('AdminPlugin'), event, user, reason, guild=guild) except: contents.append(u':x: {} - Unknown Error'.format(guild.name)) self.log.exception('Failed to force ban %s in %s', user, gid) contents.append( u':white_check_mark: {} - :regional_indicator_f:'.format( guild.name)) event.msg.reply('Results:\n' + '\n'.join(contents)) @Plugin.command('about') def command_about(self, event): embed = MessageEmbed() embed.set_author(name='Rowboat', icon_url=self.client.state.me.avatar_url, url='https://rowboat.party/') embed.description = BOT_INFO embed.add_field(name='Servers', value=str(Guild.select().count()), inline=True) embed.add_field(name='Uptime', value=humanize.naturaldelta(datetime.utcnow() - self.startup), inline=True) event.msg.reply(embed=embed) @Plugin.command('uptime', level=-1) def command_uptime(self, event): event.msg.reply('Rowboat was started {}'.format( humanize.naturaldelta(datetime.utcnow() - self.startup))) @Plugin.command('source', '<command>', level=-1) def command_source(self, event, command=None): for cmd in self.bot.commands: if command.lower() in cmd.triggers: break else: event.msg.reply(u"Couldn't find command for `{}`".format( S(command, escape_codeblocks=True))) return code = cmd.func.__code__ lines, firstlineno = inspect.getsourcelines(code) event.msg.reply( '<https://github.com/b1naryth1ef/rowboat/blob/master/{}#L{}-{}>'. format(code.co_filename, firstlineno, firstlineno + len(lines))) @Plugin.command('eval', level=-1) def command_eval(self, event): ctx = { 'bot': self.bot, 'client': self.bot.client, 'state': self.bot.client.state, 'event': event, 'msg': event.msg, 'guild': event.msg.guild, 'channel': event.msg.channel, 'author': event.msg.author } # Mulitline eval src = event.codeblock if src.count('\n'): lines = filter(bool, src.split('\n')) if lines[-1] and 'return' not in lines[-1]: lines[-1] = 'return ' + lines[-1] lines = '\n'.join(' ' + i for i in lines) code = 'def f():\n{}\nx = f()'.format(lines) local = {} try: exec compile(code, '<eval>', 'exec') in ctx, local except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return result = pprint.pformat(local['x']) else: try: result = str(eval(src, ctx)) except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return if len(result) > 1990: event.msg.reply('', attachments=[('result.txt', result)]) else: event.msg.reply(PY_CODE_BLOCK.format(result)) @Plugin.command('sync-bans', group='control', level=-1) def control_sync_bans(self, event): guilds = list(Guild.select().where(Guild.enabled == 1)) msg = event.msg.reply(':timer: pls wait while I sync...') for guild in guilds: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) msg.edit('<:{}> synced {} guilds'.format(GREEN_TICK_EMOJI, len(guilds))) @Plugin.command('reconnect', group='control', level=-1) def control_reconnect(self, event): event.msg.reply('Ok, closing connection') self.client.gw.ws.close() @Plugin.command('invite', '<guild:snowflake>', group='guilds', level=-1) def guild_join(self, event, guild): guild = self.state.guilds.get(guild) if not guild: return event.msg.reply( ':no_entry_sign: invalid or unknown guild ID') msg = event.msg.reply( u'Ok, hold on while I get you setup with an invite link to {}'. format(guild.name, )) general_channel = guild.channels[guild.id] try: invite = general_channel.create_invite( max_age=300, max_uses=1, unique=True, ) except: return msg.edit( u':no_entry_sign: Hmmm, something went wrong creating an invite for {}' .format(guild.name, )) msg.edit(u'Ok, here is a temporary invite for you: {}'.format( invite.code, )) @Plugin.command('wh', '<guild:snowflake>', group='guilds', level=-1) def guild_whitelist(self, event, guild): rdb.sadd(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply('Ok, guild %s is now in the whitelist' % guild) @Plugin.command('unwh', '<guild:snowflake>', group='guilds', level=-1) def guild_unwhitelist(self, event, guild): rdb.srem(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply( 'Ok, I\'ve made sure guild %s is no longer in the whitelist' % guild) @Plugin.command('disable', '<plugin:str>', group='plugins', level=-1) def plugin_disable(self, event, plugin): plugin = self.bot.plugins.get(plugin) if not plugin: return event.msg.reply( 'Hmmm, it appears that plugin doesn\'t exist!?') self.bot.rmv_plugin(plugin.__class__) event.msg.reply('Ok, that plugin has been disabled and unloaded') @Plugin.command('commands', group='control', level=-1) def control_commands(self, event): event.msg.reply( '__**Punishments**__\n`!mute <mention or ID> [reason]` - Mutes user from talking in text channel (role must be set up).\n`!unmute <mention or ID> [reason]` - Unmutes user.\n`!tempmute <mention or ID> <duration> [reason]` - Temporarily mutes user from talking in text channel for duration.\n`!kick <mention or ID> [reason]` - Kicks user from server.\n`!ban <mention or ID> [reason]` - Bans user, does not delete messages. Must still be in server.\n`!unban <ID> [reason]` - Unbans user. Must use ID.\n`!tempban <mention or ID> <duration> [reason]` - Temporarily bans user for duration.\n`!forceban <ID> <reason>` - Bans user who is not in the server. Must use ID.\n`!softban <mention or ID> [reason]` - Softbans (bans/unbans) user.\n\n__**Admin Utilities**__\n`!clean all <#>` - Deletes # of messages in current channel.\n`!clean bots <#>` - Deletes # of messages sent by bots in current channel.\n`!clean user <mention or ID> <#>` - Deletes # of user\'s messages in current channel. Must use ID if they are no longer in the server.\n`!archive user <mention or ID> [#]` - Creates an archive with all messages found by user.\n`!archive (here / all ) [#]` - Creates an archive with all messages in the server.\n`!archive channel <channel> [#]` - Creates an archive with all messages in the channel.\n`!search <tag or ID>` - Returns some information about the user, ID, time joined, infractions, etc.\n`!role add <mention or ID> <role> [reason]` - Adds the role (either ID or fuzzy-match name) to the user.\n`!role rmv/remove <mention or ID> <role> [reason]` - Removes the role from the user.\n`!r add <duration> <message>` - Adds a reminder to be sent after the specified duration.' ) event.msg.reply( '__**Infractions**__\n`!inf search <mention or ID>` - Searches infractions based on the given query.\n`!inf info <inf #>` - Returns information on an infraction.\n`!inf duration <inf #> <duration>` - Updates the duration of an infraction ([temp]ban, [temp]mutes). Duration starts from time of initial action.\n`!reason <inf #> <reason>` - Sets the reason for an infraction.\n\n__**Reactions and Starboard**__\n`!stars <lock | unlock>` - Lock or unlocks the starboard. Locking prevents new posts from being starred.\n`!stars <block | unblock> <mention or ID>` - Blocks or unblocks a user\'s stars from starboard. Their reactions won\'t count and messages won\'t be posted.\n`!stars hide <message ID>` - Removes a starred message from starboard using message ID.\n`!reactions clean <user> [count] [emoji]` - Removes reactions placed by specified user.' )
class VoiceClient(LoggingClass): VOICE_GATEWAY_VERSION = 3 SUPPORTED_MODES = { 'xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305', } def __init__(self, channel, encoder=None, max_reconnects=5): super(VoiceClient, self).__init__() if not channel.is_voice: raise ValueError( 'Cannot spawn a VoiceClient for a non-voice channel') self.channel = channel self.client = self.channel.client self.encoder = encoder or JSONEncoder self.max_reconnects = max_reconnects # Bind to some WS packets self.packets = Emitter() self.packets.on(VoiceOPCode.HELLO, self.on_voice_hello) self.packets.on(VoiceOPCode.READY, self.on_voice_ready) self.packets.on(VoiceOPCode.RESUMED, self.on_voice_resumed) self.packets.on(VoiceOPCode.SESSION_DESCRIPTION, self.on_voice_sdp) # State + state change emitter self.state = VoiceState.DISCONNECTED self.state_emitter = Emitter() # Connection metadata self.token = None self.endpoint = None self.ssrc = None self.port = None self.mode = None self.udp = None # Websocket connection self.ws = None self._session_id = None self._reconnects = 0 self._update_listener = None self._heartbeat_task = None def __repr__(self): return u'<VoiceClient {}>'.format(self.channel) def set_state(self, state): self.log.debug('[%s] state %s -> %s', self, self.state, state) prev_state = self.state self.state = state self.state_emitter.emit(state, prev_state) def _connect_and_run(self): self.ws = Websocket('wss://' + self.endpoint + '/v={}'.format(self.VOICE_GATEWAY_VERSION)) self.ws.emitter.on('on_open', self.on_open) self.ws.emitter.on('on_error', self.on_error) self.ws.emitter.on('on_close', self.on_close) self.ws.emitter.on('on_message', self.on_message) self.ws.run_forever() def _heartbeat(self, interval): while True: self.send(VoiceOPCode.HEARTBEAT, time.time()) gevent.sleep(interval / 1000) def set_speaking(self, value): self.send(VoiceOPCode.SPEAKING, { 'speaking': value, 'delay': 0, }) def send(self, op, data): self.log.debug('[%s] sending OP %s (data = %s)', self, op, data) self.ws.send(self.encoder.encode({ 'op': op.value, 'd': data, }), self.encoder.OPCODE) def on_voice_hello(self, data): self.log.info( '[%s] Recieved Voice HELLO payload, starting heartbeater', self) self._heartbeat_task = gevent.spawn(self._heartbeat, data['heartbeat_interval'] * 0.75) self.set_state(VoiceState.AUTHENTICATED) def on_voice_ready(self, data): self.log.info( '[%s] Recived Voice READY payload, attempting to negotiate voice connection w/ remote', self) self.set_state(VoiceState.CONNECTING) self.ssrc = data['ssrc'] self.port = data['port'] for mode in self.SUPPORTED_MODES: if mode in data['modes']: self.mode = mode self.log.debug('[%s] Selected mode %s', self, mode) break else: raise Exception('Failed to find a supported voice mode') self.log.debug('[%s] Attempting IP discovery over UDP to %s:%s', self, self.endpoint, self.port) self.udp = UDPVoiceClient(self) ip, port = self.udp.connect(self.endpoint, self.port) if not ip: self.log.error( 'Failed to discover our IP, perhaps a NAT or firewall is f*****g us' ) self.disconnect() return self.log.debug( '[%s] IP discovery completed (ip = %s, port = %s), sending SELECT_PROTOCOL', self, ip, port) self.send( VoiceOPCode.SELECT_PROTOCOL, { 'protocol': 'udp', 'data': { 'port': port, 'address': ip, 'mode': self.mode, }, }) def on_voice_resumed(self, data): self.log.info('[%s] Recieved resumed', self) self.set_state(VoiceState.CONNECTED) def on_voice_sdp(self, sdp): self.log.info( '[%s] Recieved session description, connection completed', self) # Create a secret box for encryption/decryption self.udp.setup_encryption(bytes(bytearray(sdp['secret_key']))) # Toggle speaking state so clients learn of our SSRC self.set_speaking(True) self.set_speaking(False) gevent.sleep(0.25) self.set_state(VoiceState.CONNECTED) def on_voice_server_update(self, data): if self.channel.guild_id != data.guild_id or not data.token: return if self.token and self.token != data.token: return self.log.info( '[%s] Recieved VOICE_SERVER_UPDATE (state = %s / endpoint = %s)', self, self.state, data.endpoint) self.token = data.token self.set_state(VoiceState.AUTHENTICATING) self.endpoint = data.endpoint.split(':', 1)[0] self._connect_and_run() def on_message(self, msg): try: data = self.encoder.decode(msg) self.packets.emit(VoiceOPCode[data['op']], data['d']) except Exception: self.log.exception('Failed to parse voice gateway message: ') def on_error(self, err): self.log.error('[%s] Voice websocket error: %s', self, err) def on_open(self): if self._session_id: return self.send( VoiceOPCode.RESUME, { 'server_id': self.channel.guild_id, 'user_id': self.client.state.me.id, 'session_id': self._session_id, 'token': self.token, }) self._session_id = self.client.gw.session_id self.send( VoiceOPCode.IDENTIFY, { 'server_id': self.channel.guild_id, 'user_id': self.client.state.me.id, 'session_id': self._session_id, 'token': self.token, }) def on_close(self, code, reason): self.log.warning('[%s] Voice websocket closed: [%s] %s (%s)', self, code, reason, self._reconnects) if self._heartbeat_task: self._heartbeat_task.kill() # If we're not in a connected state, don't try to resume/reconnect if self.state != VoiceState.CONNECTED: return self.log.info('[%s] Attempting Websocket Resumption', self) self._reconnects += 1 if self.max_reconnects and self._reconnects > self.max_reconnects: raise VoiceException( 'Failed to reconnect after {} attempts, giving up'.format( self.max_reconnects)) self.set_state(VoiceState.RECONNECTING) # Don't resume for these error codes: if code and 4000 <= code <= 4016: self._session_id = None if self.udp and self.udp.connected: self.udp.disconnect() wait_time = (self._reconnects - 1) * 5 self.log.info('[%s] Will attempt to %s after %s seconds', self, 'resume' if self._session_id else 'reconnect', wait_time) gevent.sleep(wait_time) self._connect_and_run() def connect(self, timeout=5, mute=False, deaf=False): self.log.debug('[%s] Attempting connection', self) self.set_state(VoiceState.AWAITING_ENDPOINT) self._update_listener = self.client.events.on( 'VoiceServerUpdate', self.on_voice_server_update) self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': mute, 'self_deaf': deaf, 'guild_id': int(self.channel.guild_id), 'channel_id': int(self.channel.id), }) if not self.state_emitter.once(VoiceState.CONNECTED, timeout=timeout): self.disconnect() raise VoiceException('Failed to connect to voice', self) def disconnect(self): self.log.debug('[%s] disconnect called', self) self.set_state(VoiceState.DISCONNECTED) if self._heartbeat_task: self._heartbeat_task.kill() self._heartbeat_task = None if self.ws and self.ws.sock and self.ws.sock.connected: self.ws.close() if self.udp and self.udp.connected: self.udp.disconnect() self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': False, 'self_deaf': False, 'guild_id': int(self.channel.guild_id), 'channel_id': None, }) def send_frame(self, *args, **kwargs): self.udp.send_frame(*args, **kwargs) def increment_timestamp(self, *args, **kwargs): self.udp.increment_timestamp(*args, **kwargs)
class Player(object): Events = Enum( 'START_PLAY', 'STOP_PLAY', 'PAUSE_PLAY', 'RESUME_PLAY', 'DISCONNECT', ) def __init__(self, client, queue=None): self.client = client # Queue contains playable items self.queue = queue or PlayableQueue() # Whether we're playing music (true for lifetime) self.playing = True # Set to an event when playback is paused self.paused = None # Current playing item self.now_playing = None # Current play task self.play_task = None # Core task self.run_task = gevent.spawn(self.run) # Event triggered when playback is complete self.complete = gevent.event.Event() # Event emitter for metadata self.events = Emitter(spawn_each=True) def disconnect(self): self.client.disconnect() self.events.emit(self.Events.DISCONNECT) def skip(self): self.play_task.kill() def pause(self): if self.paused: return self.paused = gevent.event.Event() self.events.emit(self.Events.PAUSE_PLAY) def resume(self): if self.paused: self.paused.set() self.paused = None self.events.emit(self.Events.RESUME_PLAY) def play(self, item): # Grab the first frame before we start anything else, sometimes playables # can do some lengthy async tasks here to setup the playable and we # don't want that lerp the first N frames of the playable into playing # faster frame = item.next_frame() if frame is None: return start = time.time() loops = 0 while True: loops += 1 if self.paused: self.client.set_speaking(False) self.paused.wait() gevent.sleep(2) self.client.set_speaking(True) start = time.time() loops = 0 if self.client.state == VoiceState.DISCONNECTED: return if self.client.state != VoiceState.CONNECTED: self.client.state_emitter.wait(VoiceState.CONNECTED) self.client.send_frame(frame) self.client.timestamp += item.samples_per_frame if self.client.timestamp > MAX_TIMESTAMP: self.client.timestamp = 0 frame = item.next_frame() if frame is None: return next_time = start + 0.02 * loops delay = max(0, 0.02 + (next_time - time.time())) gevent.sleep(delay) def run(self): self.client.set_speaking(True) while self.playing: self.now_playing = self.queue.get() self.events.emit(self.Events.START_PLAY, self.now_playing) self.play_task = gevent.spawn(self.play, self.now_playing) self.play_task.join() self.events.emit(self.Events.STOP_PLAY, self.now_playing) if self.client.state == VoiceState.DISCONNECTED: self.playing = False self.complete.set() return self.client.set_speaking(False) self.disconnect()
class Player(LoggingClass): Events = Enum( 'START_PLAY', 'STOP_PLAY', 'PAUSE_PLAY', 'RESUME_PLAY', 'DISCONNECT', ) def __init__(self, client, queue=None): super(Player, self).__init__() self.client = client self.last_activity = time.time() self.force_kick = False self.already_play = False self.force_return = 1 self.max_returns = 1 self.sleep_time_returns = 3 # replay self.replay = False # Text channel self.text_id = None # Queue contains playable items self.queue = queue or PlayableQueue() # Whether we're playing music (true for lifetime) self.playing = True # Set to an event when playback is paused self.paused = None # Current playing item self.now_playing = None # Last playing item self.then_playing = None # Current play task self.play_task = None # Core task self.run_task = gevent.spawn(self.run) # Event triggered when playback is complete self.complete = gevent.event.Event() # Event emitter for metadata self.events = Emitter() def disconnect(self): self.client.disconnect() self.events.emit(self.Events.DISCONNECT) def skip(self): if self.now_playing and self.now_playing.source: self.now_playing.source.killed() else: print u'source have unknown type???' def pause(self): if self.paused: return self.paused = gevent.event.Event() self.events.emit(self.Events.PAUSE_PLAY) def resume(self): if self.paused: self.paused.set() self.paused = None self.events.emit(self.Events.RESUME_PLAY) def play(self, item): # Grab the first frame before we start anything else, sometimes playables # can do some lengthy async tasks here to setup the playable and we # don't want that lerp the first N frames of the playable into playing # faster frame = item.next_frame() if frame is None: return connected = True start = time.time() loops = 0 if getattr(item.source, "broadcast", True) and getattr( item.source, "embed", None): try: gevent.spawn(self.client.client.api.channels_messages_create, channel=self.text_id, embed=item.source.embed) except Exception as error: print error pass try: print item except Exception as error: print error pass while True: loops += 1 if self.client.state == VoiceState.DISCONNECTED: return if self.client.state != VoiceState.CONNECTED: self.client.state_emitter.once(VoiceState.CONNECTED, timeout=30) # Send the voice frame and increment our timestamp try: self.client.send_frame(frame) self.client.increment_timestamp(item.samples_per_frame) self.client.set_speaking(True) except WebSocketConnectionClosedException as error: connected = False print "WS Error: {}, gid: {}".format( error, self.client.channel.guild_id) self.client.set_state(VoiceState.RECONNECTING) while self.force_return and self.max_returns < 15 and not connected: # and item.source.proc_working: print "gid: {}, try number: {}, connect to WebSocket".format( self.client.channel.guild_id, self.max_returns) self.max_returns += 1 try: #self.client.connect() self.client.state_emitter.once(VoiceState.CONNECTED, timeout=5) self.max_returns = 0 connected = True except Exception as error: print "gid: {}, connect error: {}, sleep... {} sec".format( self.client.channel.guild_id, error, self.sleep_time_returns) gevent.sleep(self.sleep_time_returns) pass if not self.max_returns < 15: self.client.set_state(VoiceState.DISCONNECTED) return # Check proc live if not item.source.proc_working: return # Get next frame = item.next_frame() self.last_activity = time.time() if frame is None: return next_time = start + 0.02 * loops delay = max(0, 0.02 + (next_time - time.time())) item.source.played += delay gevent.sleep(delay) def run(self): while self.playing: self.now_playing = self.queue.get() self.events.emit(self.Events.START_PLAY, self.now_playing) self.play_task = gevent.spawn(self.play, self.now_playing) self.already_play = True self.last_activity = time.time() self.play_task.join() self.events.emit(self.Events.STOP_PLAY, self.now_playing) self.now_playing = None self.already_play = False if self.client.state == VoiceState.DISCONNECTED: self.playing = False self.queue.clear() self.complete.set() self.client.set_speaking(False) self.disconnect()
class CorePlugin(Plugin): def load(self, ctx): init_db(ENV) self.startup = ctx.get('startup', datetime.utcnow()) self.guilds = ctx.get('guilds', {}) self.emitter = Emitter(gevent.spawn) super(CorePlugin, self).load(ctx) # Overwrite the main bot instances plugin loader so we can magicfy events self.bot.add_plugin = self.our_add_plugin self.spawn(self.wait_for_plugin_changes) self.global_config = None with open('config.yaml', 'r') as f: self.global_config = load(f) self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) def spawn_wait_for_actions(self, *args, **kwargs): self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) self._wait_for_actions_greenlet.link_exception( self.spawn_wait_for_actions) def our_add_plugin(self, cls, *args, **kwargs): if getattr(cls, 'global_plugin', False): Bot.add_plugin(self.bot, cls, *args, **kwargs) return inst = cls(self.bot, None) inst.register_trigger('command', 'pre', functools.partial(self.on_pre, inst)) inst.register_trigger('listener', 'pre', functools.partial(self.on_pre, inst)) Bot.add_plugin(self.bot, inst, *args, **kwargs) def wait_for_plugin_changes(self): import gevent_inotifyx as inotify fd = inotify.init() inotify.add_watch(fd, 'rowboat/plugins/', inotify.IN_MODIFY) while True: events = inotify.get_events(fd) for event in events: # Can't reload core.py sadly if event.name.startswith('core.py'): continue plugin_name = '{}Plugin'.format( event.name.split('.', 1)[0].title()) plugin = next((v for k, v in self.bot.plugins.items() if k.lower() == plugin_name.lower()), None) if plugin: self.log.info('Detected change in %s, reloading...', plugin_name) try: plugin.reload() except Exception: self.log.exception('Failed to reload: ') def wait_for_actions(self): ps = rdb.pubsub() ps.subscribe('actions') for item in ps.listen(): if item['type'] != 'message': continue data = json.loads(item['data']) if data['type'] == 'GUILD_UPDATE' and data['id'] in self.guilds: with self.send_config_message() as embed: embed.title = u'Reloaded config for {}'.format( self.guilds[data['id']].name) self.log.info(u'Reloading guild %s', self.guilds[data['id']].name) # Refresh config, mostly to validate try: config = self.guilds[data['id']].get_config(refresh=True) # Reload the guild entirely self.guilds[data['id']] = Guild.with_id(data['id']) # Update guild access self.update_rowboat_guild_access() # Finally, emit the event self.emitter.emit('GUILD_CONFIG_UPDATE', self.guilds[data['id']], config) except: self.log.exception(u'Failed to reload config for guild %s', self.guilds[data['id']].name) continue elif data['type'] == 'RESTART': self.log.info('Restart requested, signaling parent') os.kill(os.getppid(), signal.SIGUSR1) elif data['type'] == 'GUILD_DELETE' and data['id'] in self.guilds: with self.send_config_message() as embed: embed.color = 0xff6961 embed.title = u'Guild Force Deleted {}'.format( self.guilds[data['id']].name, ) self.log.info(u'Leaving guild %s', self.guilds[data['id']].name) self.guilds[data['id']].leave() def unload(self, ctx): ctx['guilds'] = self.guilds ctx['startup'] = self.startup super(CorePlugin, self).unload(ctx) def update_rowboat_guild_access(self): if ROWBOAT_GUILD_ID not in self.state.guilds or ENV != 'prod': return rb_guild = self.state.guilds.get(ROWBOAT_GUILD_ID) if not rb_guild: return self.log.info('Updating Airplane guild access') guilds = Guild.select(Guild.guild_id, Guild.config).where( (Guild.enabled == 1)) users_who_should_have_access = set() for guild in guilds: if 'web' not in guild.config: continue for user_id in guild.config['web'].keys(): try: users_who_should_have_access.add(int(user_id)) except: self.log.warning('Guild %s has invalid user ACLs: %s', guild.guild_id, guild.config['web']) # TODO: sharding users_who_have_access = { i.id for i in rb_guild.members.values() if ROWBOAT_USER_ROLE_ID in i.roles } remove_access = set(users_who_have_access) - set( users_who_should_have_access) add_access = set(users_who_should_have_access) - set( users_who_have_access) for user_id in remove_access: member = rb_guild.members.get(user_id) if not member: continue member.remove_role(ROWBOAT_USER_ROLE_ID) for user_id in add_access: member = rb_guild.members.get(user_id) if not member: continue member.add_role(ROWBOAT_USER_ROLE_ID) def on_pre(self, plugin, func, event, args, kwargs): """ This function handles dynamically dispatching and modifying events based on a specific guilds configuration. It is called before any handler of either commands or listeners. """ if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None if guild_id not in self.guilds: if isinstance(event, CommandEvent): if event.command.metadata.get('global_', False): return event elif hasattr(func, 'subscriptions'): if func.subscriptions[0].metadata.get('global_', False): return event return if hasattr(plugin, 'WHITELIST_FLAG'): if not int( plugin.WHITELIST_FLAG) in self.guilds[guild_id].whitelist: return event.base_config = self.guilds[guild_id].get_config() if not event.base_config: return plugin_name = plugin.name.lower().replace('plugin', '') if not getattr(event.base_config.plugins, plugin_name, None): return self._attach_local_event_data(event, plugin_name, guild_id) return event def get_config(self, guild_id, *args, **kwargs): # Externally Used return self.guilds[guild_id].get_config(*args, **kwargs) def get_guild(self, guild_id): # Externally Used return self.guilds[guild_id] def _attach_local_event_data(self, event, plugin_name, guild_id): if not hasattr(event, 'config'): event.config = LocalProxy() if not hasattr(event, 'rowboat_guild'): event.rowboat_guild = LocalProxy() event.config.set(getattr(event.base_config.plugins, plugin_name)) event.rowboat_guild.set(self.guilds[guild_id]) @Plugin.schedule(290, init=False) def update_guild_bans(self): to_update = [ guild for guild in Guild.select().where(( Guild.last_ban_sync < (datetime.utcnow() - timedelta(days=1))) | (Guild.last_ban_sync >> None)) if guild.guild_id in self.client.state.guilds ] # Update 10 at a time for guild in to_update[:10]: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) @Plugin.listen('GuildUpdate') def on_guild_update(self, event): self.log.info('Got guild update for guild %s (%s)', event.guild.id, event.guild.channels) # @Plugin.listen('GuildMembersChunk') # def on_guild_members_chunk(self, event): # self.log.info('Got members chunk for guild %s', event.guild_id) @Plugin.listen('GuildBanAdd') def on_guild_ban_add(self, event): GuildBan.ensure(self.client.state.guilds.get(event.guild_id), event.user) @Plugin.listen('GuildBanRemove') def on_guild_ban_remove(self, event): GuildBan.delete().where((GuildBan.user_id == event.user.id) & (GuildBan.guild_id == event.guild_id)) @contextlib.contextmanager def send_launch_message(self): embed = MessageEmbed() embed.set_footer(text='Airplane {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_LAUNCH_CHANNEL, embed=embed) except: self.log.exception('Failed to send control message:') return @contextlib.contextmanager def send_config_message(self): embed = MessageEmbed() embed.set_footer(text='Airplane {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_CONFIG_CHANNEL, embed=embed) except: self.log.exception('Failed to send spam control message:') return @contextlib.contextmanager def send_error_message(self): embed = MessageEmbed() embed.set_footer(text='Airplane {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create(ROWBOAT_ERROR_CHANNEL, embed=embed) except: self.log.exception('Failed to send spam control message:') return @Plugin.listen('Resumed') def on_resumed(self, event): Notification.dispatch( Notification.Types.RESUME, trace=event.trace, env=ENV, ) with self.send_launch_message() as embed: embed.title = 'Resumed' embed.color = 0xffb347 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) embed.add_field(name='Replayed Events', value=str(self.client.gw.replayed_events)) @Plugin.listen('Ready', priority=Priority.BEFORE) def on_ready(self, event): reconnects = self.client.gw.reconnects self.log.info('Started session %s', event.session_id) Notification.dispatch( Notification.Types.CONNECT, trace=event.trace, env=ENV, ) with self.send_launch_message() as embed: if reconnects: embed.title = 'Reconnected' embed.color = 0xffb347 else: embed.title = 'Connected' embed.color = 0x77dd77 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) @Plugin.listen('GuildCreate', priority=Priority.BEFORE, conditional=lambda e: not e.created) def on_guild_create(self, event): try: guild = Guild.with_id(event.id) except Guild.DoesNotExist: # If the guild is not awaiting setup, leave it now if not rdb.sismember(GUILDS_WAITING_SETUP_KEY, str( event.id)) and event.id != ROWBOAT_GUILD_ID: self.log.warning( 'Leaving guild %s (%s), not within setup list', event.id, event.name) event.guild.leave() return if not guild.enabled: return config = guild.get_config() if not config: return # Ensure we're updated self.log.info('Syncing guild %s', event.guild.id) guild.sync(event.guild) self.guilds[event.id] = guild if config.nickname: def set_nickname(): m = event.members.select_one(id=self.state.me.id) if m and m.nick != config.nickname: try: m.set_nickname(config.nickname) except APIException as e: self.log.warning( 'Failed to set nickname for guild %s (%s)', event.guild, e.content) self.spawn_later(5, set_nickname) def get_level(self, guild, user): config = (guild.id in self.guilds and self.guilds.get(guild.id).get_config()) user_level = 0 if config: member = guild.get_member(user) if not member: return user_level for oid in member.roles: if oid in config.levels and config.levels[oid] > user_level: user_level = config.levels[oid] # User ID overrides should override all others if member.id in config.levels: user_level = config.levels[member.id] return user_level @Plugin.listen('MessageCreate') def on_message_create(self, event): """ This monstrosity of a function handles the parsing and dispatching of commands. """ # Ignore messages sent by bots if event.message.author.bot: return if rdb.sismember('ignored_channels', event.message.channel_id): return if event.message.content.startswith('gh/'): event.message.reply('https://github.com/{}/{}'.format( event.message.content.split('/')[1], event.message.content.split('/')[2])) return # If this is message for a guild, grab the guild object if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None guild = self.guilds.get(event.guild.id) if guild_id else None config = guild and guild.get_config() # If the guild has configuration, use that (otherwise use defaults) if config and config.commands: commands = list( self.bot.get_commands_for_message(config.commands.mention, {}, config.commands.prefix, event.message)) elif guild_id: # Otherwise, default to requiring mentions commands = list( self.bot.get_commands_for_message(True, {}, '', event.message)) else: if ENV != 'prod': if not event.message.content.startswith(ENV + '!'): return event.message.content = event.message.content[len(ENV) + 1:] # DM's just use the commands (no prefix/mention) commands = list( self.bot.get_commands_for_message(False, {}, '', event.message)) # If we didn't find any matching commands, return if not len(commands): return event.user_level = self.get_level(event.guild, event.author) if event.guild else 0 # Grab whether this user is a global admin # TODO: cache this global_admin = rdb.sismember('global_admins', event.author.id) # Iterate over commands and find a match for command, match in commands: if command.level == -1 and not global_admin: continue level = command.level if guild and not config and command.triggers[0] != 'setup': continue elif config and config.commands and command.plugin != self: overrides = {} for obj in config.commands.get_command_override(command): overrides.update(obj) if overrides.get('disabled'): continue level = overrides.get('level', level) if not global_admin and event.user_level < level: continue with timed('rowboat.command.duration', tags={ 'plugin': command.plugin.name, 'command': command.name }): try: command_event = CommandEvent(command, event.message, match) command_event.user_level = event.user_level command.plugin.execute(command_event) except CommandResponse as e: event.reply(e.response) except: tracked = Command.track(event, command, exception=True) self.log.exception('Command error:') with self.send_error_message() as embed: embed.title = u'Command Error: {}'.format(command.name) embed.color = 0xff6961 embed.add_field(name='Server', value='({}) `{}`'.format( event.guild.name, event.guild.id), inline=False) embed.add_field(name='Exact Command Ran', value='`{}`'.format(event.content)) embed.add_field(name='Author', value='(`{}`) `{}`'.format( unicode( event.author).encode('utf-8'), event.author.id), inline=True) embed.add_field(name='Channel', value='({}) `{}`'.format( event.channel.name, event.channel.id), inline=True) embed.description = '```{}```'.format(u'\n'.join( tracked.traceback.split('\n')[-8:])) return event.reply( '<:{}> something went wrong, perhaps try again later'. format(RED_TICK_EMOJI)) Command.track(event, command) # Dispatch the command used modlog event if config: modlog_config = getattr(config.plugins, 'modlog', None) if not modlog_config: return self._attach_local_event_data(event, 'modlog', event.guild.id) plugin = self.bot.plugins.get('ModLogPlugin') if plugin: plugin.log_action(Actions.COMMAND_USED, event) return @Plugin.command('setup') def command_setup(self, event): if not event.guild: return event.msg.reply( ':warning: this command can only be used in servers') # Make sure we're not already setup if event.guild.id in self.guilds: return event.msg.reply(':warning: this server is already setup') global_admin = rdb.sismember('global_admins', event.author.id) # Make sure this is the owner of the server if not global_admin: if not event.guild.owner_id == event.author.id: return event.msg.reply( ':warning: only the server owner can setup Airplane') # Make sure we have admin perms m = event.guild.members.select_one(id=self.state.me.id) if not m.permissions.administrator and not global_admin: return event.msg.reply( ':warning: bot must have the Administrator permission') guild = Guild.setup(event.guild) rdb.srem(GUILDS_WAITING_SETUP_KEY, str(event.guild.id)) self.guilds[event.guild.id] = guild event.msg.reply(':ok_hand: successfully loaded configuration') @Plugin.command('about') def command_about(self, event): embed = MessageEmbed() embed.set_author(name='Airplane', icon_url=self.client.state.me.avatar_url, url='https://dash.airplane.gg/') embed.description = BOT_INFO embed.add_field(name='Servers', value=str(Guild.select().count()), inline=True) embed.add_field(name='Uptime', value=humanize.naturaldelta(datetime.utcnow() - self.startup), inline=True) event.msg.reply(embed=embed) @Plugin.command('uptime', level=-1) def command_uptime(self, event): event.msg.reply('Airplane has been up for {}'.format( humanize.naturaldelta(datetime.utcnow() - self.startup))) @Plugin.command('source', '<command>', level=-1) def command_source(self, event, command=None): for cmd in self.bot.commands: if command.lower() in cmd.triggers: break else: event.msg.reply(u"Couldn't find command for `{}`".format( S(command, escape_codeblocks=True))) return code = cmd.func.__code__ lines, firstlineno = inspect.getsourcelines(code) event.msg.reply( '<https://github.com/OGNova/airplane/blob/master/{}#L{}-{}>'. format(code.co_filename, firstlineno, firstlineno + len(lines))) @Plugin.command('eval', level=-1) def command_eval(self, event): ctx = { 'bot': self.bot, 'client': self.bot.client, 'state': self.bot.client.state, 'event': event, 'msg': event.msg, 'guild': event.msg.guild, 'channel': event.msg.channel, 'author': event.msg.author } # Mulitline eval src = event.codeblock if src.count('\n'): lines = filter(bool, src.split('\n')) if lines[-1] and 'return' not in lines[-1]: lines[-1] = 'return ' + lines[-1] lines = '\n'.join(' ' + i for i in lines) code = 'def f():\n{}\nx = f()'.format(lines) local = {} try: exec compile(code, '<eval>', 'exec') in ctx, local except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return result = pprint.pformat(local['x']) else: try: result = str(eval(src, ctx)) except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return if len(result) > 1990: event.msg.reply('', attachments=[('result.txt', result)]) else: event.msg.reply(PY_CODE_BLOCK.format(result)) @Plugin.command('sync-bans', group='control', level=-1) def control_sync_bans(self, event): guilds = list(Guild.select().where(Guild.enabled == 1)) msg = event.msg.reply(':timer: pls wait while I sync...') for guild in guilds: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) msg.edit('<:{}> synced {} guilds'.format(GREEN_TICK_EMOJI, len(guilds))) @Plugin.command('reconnect', group='control', level=-1) def control_reconnect(self, event): event.msg.reply('Ok, closing connection') self.client.gw.ws.close() @Plugin.command('invite', '<guild:snowflake>', group='guilds', level=-1) def guild_join(self, event, guild): guild = self.state.guilds.get(guild) if not guild: return event.msg.reply( '<:deny:470285164313051138> invalid or unknown guild ID') msg = event.msg.reply( u'Ok, hold on while I get you setup with an invite link to {}'. format(guild.name, )) general_channel = guild.channels[guild.id] try: invite = general_channel.create_invite( max_age=300, max_uses=1, unique=True, ) except: return msg.edit( u'<:deny:470285164313051138> Hmmm, something went wrong creating an invite for {}' .format(guild.name, )) msg.edit(u'Ok, here is a temporary invite for you: {}'.format( invite.code, )) @Plugin.command('locate', '<user:user|snowflake>', level=-1) def command_locate(self, event, user): if isinstance(user, (int, long)): uid = user user = self.state.users.get(uid) if not user: return event.msg.reply('User {} not found.'.format(uid)) buff = '' count = 0 guilds = self.state.guilds.values() guilds = sorted(guilds, key=lambda g: g.name) for guild in guilds: member = guild.members.get(user.id) if not member: continue guild = S(u'{} - {} (level: {})\n'.format( guild.id, guild.name, self.get_level(guild, user.id)), escape_codeblocks=True) if len(guild) + len(buff) > 1920: event.msg.reply(u'```{}```'.format(buff)) buff = '' buff += guild count += 1 user = u'{} ({} - {})'.format(user, user.mention, user.id) if not count: return event.msg.reply(u'User {} not found.'.format(user)) event.msg.reply(u'```{}```*User {} found in {} server{}.*'.format( buff, user, count, 's' if count > 1 else '')) @Plugin.command('wh', '<guild:snowflake>', group='guilds', level=-1) def guild_whitelist(self, event, guild): rdb.sadd(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply('Ok, guild %s is now in the whitelist' % guild) @Plugin.command('unwh', '<guild:snowflake>', group='guilds', level=-1) def guild_unwhitelist(self, event, guild): rdb.srem(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply( 'Ok, I\'ve made sure guild %s is no longer in the whitelist' % guild) @Plugin.command('disable', '<plugin:str>', group='plugins', level=-1) def plugin_disable(self, event, plugin): plugin = self.bot.plugins.get(plugin) if not plugin: return event.msg.reply( 'Hmmm, it appears that plugin doesn\'t exist!?') self.bot.rmv_plugin(plugin.__class__) event.msg.reply('Ok, that plugin has been disabled and unloaded') def default_color(self, avatar_color): switcher = { 'blurple': "https://cdn.discordapp.com/embed/avatars/0.png", 'grey': "https://cdn.discordapp.com/embed/avatars/1.png", 'green': "https://cdn.discordapp.com/embed/avatars/2.png", 'orange': "https://cdn.discordapp.com/embed/avatars/3.png", 'red': "https://cdn.discordapp.com/embed/avatars/4.png" } return switcher.get(avatar_color) @Plugin.listen('MessageCreate') def dm_listener(self, event): from rowboat.util.images import get_dominant_colors_user global_admin = rdb.sismember('global_admins', event.author.id) if global_admin or event.author.id == 351097525928853506: return if event.guild == None: MODIFIER_GRAVE_ACCENT = u'\u02CB' msg = event message_content = msg.content.replace('`', MODIFIER_GRAVE_ACCENT) author_id = msg.author.id discrim = str(msg.author.discriminator) avatar_name = msg.author.avatar content = [] embed = MessageEmbed() if not avatar_name: avatar = default_color(str(msg.author.default_avatar)) elif avatar_name.startswith('a_'): avatar = u'https://cdn.discordapp.com/avatars/{}/{}.gif'.format( author_id, avatar_name) else: avatar = u'https://cdn.discordapp.com/avatars/{}/{}.png'.format( author_id, avatar_name) embed.set_author(name='{} ({})'.format(msg.author, author_id), icon_url=avatar) embed.set_thumbnail(url=avatar) content.append(u'**\u276F New DM:**') content.append(u'Content: ```{}```'.format(message_content)) embed.description = '\n'.join(content) embed.timestamp = datetime.utcnow().isoformat() try: embed.color = get_dominant_colors_user(msg.author, avatar) except: embed.color = '00000000' self.bot.client.api.channels_messages_create('540020613272829996', '', embed=embed)
class VoiceClient(LoggingClass): def __init__(self, channel, encoder=None): super(VoiceClient, self).__init__() assert channel.is_voice, 'Cannot spawn a VoiceClient for a non-voice channel' self.channel = channel self.client = self.channel.client self.encoder = encoder or JSONEncoder self.packets = Emitter(gevent.spawn) self.packets.on(VoiceOPCode.READY, self.on_voice_ready) self.packets.on(VoiceOPCode.SESSION_DESCRIPTION, self.on_voice_sdp) # State self.state = VoiceState.DISCONNECTED self.connected = gevent.event.Event() self.token = None self.endpoint = None self.ssrc = None self.port = None self.update_listener = None # Websocket connection self.ws = None self.heartbeat_task = None def heartbeat(self, interval): while True: self.send(VoiceOPCode.HEARTBEAT, time.time() * 1000) gevent.sleep(interval / 1000) def set_speaking(self, value): self.send(VoiceOPCode.SPEAKING, { 'speaking': value, 'delay': 0, }) def send(self, op, data): self.ws.send(self.encoder.encode({ 'op': op.value, 'd': data, }), self.encoder.OPCODE) def on_voice_ready(self, data): self.state = VoiceState.CONNECTING self.ssrc = data['ssrc'] self.port = data['port'] self.heartbeat_task = gevent.spawn(self.heartbeat, data['heartbeat_interval']) self.udp = UDPVoiceClient(self) ip, port = self.udp.connect(self.endpoint, self.port) if not ip: self.disconnect() return self.send( VoiceOPCode.SELECT_PROTOCOL, { 'protocol': 'udp', 'data': { 'port': port, 'address': ip, 'mode': 'plain' } }) def on_voice_sdp(self, data): # Toggle speaking state so clients learn of our SSRC self.set_speaking(True) self.set_speaking(False) gevent.sleep(0.25) self.state = VoiceState.CONNECTED self.connected.set() def on_voice_server_update(self, data): if self.channel.guild_id != data.guild_id or not data.token: return if self.token and self.token != data.token: return self.token = data.token self.state = VoiceState.AUTHENTICATING self.endpoint = data.endpoint.split(':', 1)[0] self.ws = Websocket( 'wss://' + self.endpoint, on_message=self.on_message, on_error=self.on_error, on_open=self.on_open, on_close=self.on_close, ) self.ws.run_forever() def on_message(self, ws, msg): try: data = self.encoder.decode(msg) except: self.log.exception('Failed to parse voice gateway message: ') self.packets.emit(VoiceOPCode[data['op']], data['d']) def on_error(self, ws, err): # TODO self.log.warning('Voice websocket error: {}'.format(err)) def on_open(self, ws): self.send( VoiceOPCode.IDENTIFY, { 'server_id': self.channel.guild_id, 'user_id': self.client.state.me.id, 'session_id': self.client.gw.session_id, 'token': self.token }) def on_close(self, ws, code, error): # TODO self.log.warning('Voice websocket disconnected (%s, %s)', code, error) def connect(self, timeout=5, mute=False, deaf=False): self.state = VoiceState.AWAITING_ENDPOINT self.update_listener = self.client.events.on( 'VoiceServerUpdate', self.on_voice_server_update) self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': mute, 'self_deaf': deaf, 'guild_id': int(self.channel.guild_id), 'channel_id': int(self.channel.id), }) if not self.connected.wait( timeout) or self.state != VoiceState.CONNECTED: raise VoiceException('Failed to connect to voice', self) def disconnect(self): self.state = VoiceState.DISCONNECTED if self.heartbeat_task: self.heartbeat_task.kill() self.heartbeat_task = None if self.ws and self.ws.sock.connected: self.ws.close() if self.udp and self.udp.connected: self.udp.disconnect() self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': False, 'self_deaf': False, 'guild_id': int(self.channel.guild_id), 'channel_id': None, })
class CorePlugin(Plugin): def load(self, ctx): init_db(ENV) self.startup = ctx.get('startup', datetime.utcnow()) self.guilds = ctx.get('guilds', {}) self.emitter = Emitter(gevent.spawn) super(CorePlugin, self).load(ctx) # Overwrite the main bot instances plugin loader so we can magicfy events self.bot.add_plugin = self.our_add_plugin if ENV != 'prod': self.spawn(self.wait_for_plugin_changes) self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) def spawn_wait_for_actions(self, *args, **kwargs): self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) self._wait_for_actions_greenlet.link_exception( self.spawn_wait_for_actions) def our_add_plugin(self, cls, *args, **kwargs): if getattr(cls, 'global_plugin', False): Bot.add_plugin(self.bot, cls, *args, **kwargs) return inst = cls(self.bot, None) inst.register_trigger('command', 'pre', functools.partial(self.on_pre, inst)) inst.register_trigger('listener', 'pre', functools.partial(self.on_pre, inst)) Bot.add_plugin(self.bot, inst, *args, **kwargs) def wait_for_plugin_changes(self): import gevent_inotifyx as inotify fd = inotify.init() inotify.add_watch(fd, 'rowboat/plugins/', inotify.IN_MODIFY) while True: events = inotify.get_events(fd) for event in events: # Can't reload core.py sadly if event.name.startswith('core.py'): continue plugin_name = '{}Plugin'.format( event.name.split('.', 1)[0].title()) plugin = next((v for k, v in self.bot.plugins.items() if k.lower() == plugin_name.lower()), None) if plugin: self.log.info('Detected change in %s, reloading...', plugin_name) try: plugin.reload() except Exception: self.log.exception('Failed to reload: ') def wait_for_actions(self): ps = rdb.pubsub() ps.subscribe('actions') for item in ps.listen(): if item['type'] != 'message': continue data = json.loads(item['data']) if data['type'] == 'GUILD_UPDATE' and data['id'] in self.guilds: with self.send_control_message() as embed: embed.title = u'Reloaded config for {}'.format( self.guilds[data['id']].name) self.log.info(u'Reloading guild %s', self.guilds[data['id']].name) # Refresh config, mostly to validate try: config = self.guilds[data['id']].get_config(refresh=True) # Reload the guild entirely self.guilds[data['id']] = Guild.with_id(data['id']) # Update guild access self.update_rowboat_guild_access() # Finally, emit the event self.emitter.emit('GUILD_CONFIG_UPDATE', self.guilds[data['id']], config) except: self.log.exception(u'Failed to reload config for guild %s', self.guilds[data['id']].name) continue elif data['type'] == 'RESTART': self.log.info('Restart requested, signaling parent') os.kill(os.getppid(), signal.SIGUSR1) elif data['type'] == 'GUILD_DELETE' and data['id'] in self.guilds: with self.send_control_message() as embed: embed.color = 0xff6961 embed.title = u'Guild Force Deleted {}'.format( self.guilds[data['id']].name, ) self.log.info(u'Leaving guild %s', self.guilds[data['id']].name) self.guilds[data['id']].leave() def unload(self, ctx): ctx['guilds'] = self.guilds ctx['startup'] = self.startup super(CorePlugin, self).unload(ctx) def update_rowboat_guild_access(self): if ROWBOAT_GUILD_ID not in self.state.guilds or (ENV != 'prod' and ENV != 'docker'): return rb_guild = self.state.guilds.get(ROWBOAT_GUILD_ID) if not rb_guild: return self.log.info('Updating rowboat guild access') guilds = Guild.select(Guild.guild_id, Guild.config).where( (Guild.enabled == 1)) users_who_should_have_access = set() for guild in guilds: if 'web' not in guild.config: continue for user_id in guild.config['web'].keys(): try: users_who_should_have_access.add(int(user_id)) except: self.log.warning('Guild %s has invalid user ACLs: %s', guild.guild_id, guild.config['web']) # TODO: sharding users_who_have_access = { i.id for i in rb_guild.members.values() if ROWBOAT_USER_ROLE_ID in i.roles } remove_access = set(users_who_have_access) - set( users_who_should_have_access) add_access = set(users_who_should_have_access) - set( users_who_have_access) for user_id in remove_access: member = rb_guild.members.get(user_id) if not member: continue member.remove_role(ROWBOAT_USER_ROLE_ID) for user_id in add_access: member = rb_guild.members.get(user_id) if not member: continue member.add_role(ROWBOAT_USER_ROLE_ID) def on_pre(self, plugin, func, event, args, kwargs): """ This function handles dynamically dispatching and modifying events based on a specific guilds configuration. It is called before any handler of either commands or listeners. """ if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None if guild_id not in self.guilds: if isinstance(event, CommandEvent): if event.command.metadata.get('global_', False): return event elif hasattr(func, 'subscriptions'): if func.subscriptions[0].metadata.get('global_', False): return event return if hasattr(plugin, 'WHITELIST_FLAG'): if not int( plugin.WHITELIST_FLAG) in self.guilds[guild_id].whitelist: return event.base_config = self.guilds[guild_id].get_config() if not event.base_config: return plugin_name = plugin.name.lower().replace('plugin', '') if not getattr(event.base_config.plugins, plugin_name, None): return self._attach_local_event_data(event, plugin_name, guild_id) return event def get_config(self, guild_id, *args, **kwargs): # Externally Used return self.guilds[guild_id].get_config(*args, **kwargs) def get_guild(self, guild_id): # Externally Used return self.guilds[guild_id] def _attach_local_event_data(self, event, plugin_name, guild_id): if not hasattr(event, 'config'): event.config = LocalProxy() if not hasattr(event, 'rowboat_guild'): event.rowboat_guild = LocalProxy() event.config.set(getattr(event.base_config.plugins, plugin_name)) event.rowboat_guild.set(self.guilds[guild_id]) @Plugin.schedule(290, init=False) def update_guild_bans(self): to_update = [ guild for guild in Guild.select().where(( Guild.last_ban_sync < (datetime.utcnow() - timedelta(days=1))) | (Guild.last_ban_sync >> None)) if guild.guild_id in self.client.state.guilds ] # Update 10 at a time for guild in to_update[:10]: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) @Plugin.listen('GuildUpdate') def on_guild_update(self, event): self.log.info('Got guild update for guild %s (%s)', event.guild.id, event.guild.channels) @Plugin.listen('GuildBanAdd') def on_guild_ban_add(self, event): GuildBan.ensure(self.client.state.guilds.get(event.guild_id), event.user) @Plugin.listen('GuildBanRemove') def on_guild_ban_remove(self, event): GuildBan.delete().where((GuildBan.user_id == event.user.id) & (GuildBan.guild_id == event.guild_id)) @contextlib.contextmanager def send_control_message(self): embed = MessageEmbed() embed.set_footer(text='RoPolice {}'.format( 'Production' if ENV == 'prod' or ENV == 'docker' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_CONTROL_CHANNEL, embed=embed) except: self.log.exception('Failed to send control message:') return @Plugin.listen('Resumed') def on_resumed(self, event): with self.send_control_message() as embed: embed.title = 'Resumed' embed.color = 0xffb347 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) embed.add_field(name='Replayed Events', value=str(self.client.gw.replayed_events)) @Plugin.listen('Ready', priority=Priority.BEFORE) def on_ready(self, event): reconnects = self.client.gw.reconnects self.log.info('Started session %s', event.session_id) with self.send_control_message() as embed: if reconnects: embed.title = 'Reconnected' embed.color = 0xffb347 else: embed.title = 'Connected' embed.color = 0x77dd77 embed.add_field(name='Gateway Server', value=event.trace[0], inline=False) embed.add_field(name='Session Server', value=event.trace[1], inline=False) @Plugin.listen('GuildCreate', priority=Priority.BEFORE, conditional=lambda e: not e.created) def on_guild_create(self, event): try: guild = Guild.with_id(event.id) except Guild.DoesNotExist: # If the guild is not awaiting setup, leave it now if not rdb.sismember(GUILDS_WAITING_SETUP_KEY, str( event.id)) and event.id != ROWBOAT_GUILD_ID: self.log.warning( 'Leaving guild %s (%s), not within setup list', event.id, event.name) event.guild.leave() return if not guild.enabled: return config = guild.get_config() if not config: return # Ensure we're updated self.log.info('Syncing guild %s', event.guild.id) guild.sync(event.guild) self.guilds[event.id] = guild if config.nickname: def set_nickname(): m = event.members.select_one(id=self.state.me.id) if m and m.nick != config.nickname: try: m.set_nickname(config.nickname) except APIException as e: self.log.warning( 'Failed to set nickname for guild %s (%s)', event.guild, e.content) self.spawn_later(5, set_nickname) def get_level(self, guild, user): config = (guild.id in self.guilds and self.guilds.get(guild.id).get_config()) user_level = 0 if config: member = guild.get_member(user) if not member: return user_level for oid in member.roles: if oid in config.levels and config.levels[oid] > user_level: user_level = config.levels[oid] # User ID overrides should override all others if member.id in config.levels: user_level = config.levels[member.id] return user_level @Plugin.listen('MessageCreate') def on_message_create(self, event): """ This monstrosity of a function handles the parsing and dispatching of commands. """ # Ignore messages sent by bots #if event.message.author.bot: # return # If this is message for a guild, grab the guild object if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None guild = self.guilds.get(event.guild.id) if guild_id else None config = guild and guild.get_config() # If the guild has configuration, use that (otherwise use defaults) if config and config.commands: commands = list( self.bot.get_commands_for_message(config.commands.mention, {}, config.commands.prefix, event.message)) elif guild_id: # Otherwise, default to requiring mentions commands = list( self.bot.get_commands_for_message(True, {}, '', event.message)) else: if ENV != 'prod': if not event.message.content.startswith(ENV + '!'): return event.message.content = event.message.content[len(ENV) + 1:] # DM's just use the commands (no prefix/mention) commands = list( self.bot.get_commands_for_message(False, {}, '', event.message)) # If we didn't find any matching commands, return if not len(commands): return event.user_level = self.get_level(event.guild, event.author) if event.guild else 0 # Grab whether this user is a global admin # TODO: cache this global_admin = rdb.sismember('global_admins', event.author.id) # Iterate over commands and find a match for command, match in commands: if command.level == -1 and not global_admin: continue level = command.level if guild and not config and command.triggers[0] != 'setup': continue elif config and config.commands and command.plugin != self: overrides = {} for obj in config.commands.get_command_override(command): overrides.update(obj) if overrides.get('disabled'): continue level = overrides.get('level', level) if not global_admin and event.user_level < level: continue with timed('rowboat.command.duration', tags={ 'plugin': command.plugin.name, 'command': command.name }): try: command_event = CommandEvent(command, event.message, match) command_event.user_level = event.user_level command.plugin.execute(command_event) except CommandResponse as e: event.reply(e.response) except: tracked = Command.track(event, command, exception=True) self.log.exception('Command error:') with self.send_control_message() as embed: embed.title = u'Command Error: {}'.format(command.name) embed.color = 0xff6961 embed.add_field(name='Author', value='({}) `{}`'.format( event.author, event.author.id), inline=True) embed.add_field(name='Channel', value='({}) `{}`'.format( event.channel.name, event.channel.id), inline=True) embed.description = '```{}```'.format(u'\n'.join( tracked.traceback.split('\n')[-8:])) return event.reply( '<:{}> something went wrong, perhaps try again later'. format(RED_TICK_EMOJI)) # Dispatch the command used modlog event if config: modlog_config = getattr(config.plugins, 'modlog', None) if not modlog_config: return self._attach_local_event_data(event, 'modlog', event.guild.id) plugin = self.bot.plugins.get('ModLogPlugin') if plugin: plugin.log_action(Actions.COMMAND_USED, event) return @Plugin.command('setup') def command_setup(self, event): if not event.guild: return event.msg.reply( ':warning: this command can only be used in servers') # Make sure we're not already setup if event.guild.id in self.guilds: return event.msg.reply(':warning: this server is already setup') global_admin = rdb.sismember('global_admins', event.author.id) # Make sure this is the owner of the server if not global_admin: if not event.guild.owner_id == event.author.id: return event.msg.reply( ':warning: only the server owner can setup ropolice') # Make sure we have admin perms m = event.guild.members.select_one(id=self.state.me.id) if not m.permissions.administrator and not global_admin: return event.msg.reply( ':warning: bot must have the Administrator permission') guild = Guild.setup(event.guild) rdb.srem(GUILDS_WAITING_SETUP_KEY, str(event.guild.id)) self.guilds[event.guild.id] = guild event.msg.reply(':ok_hand: successfully loaded configuration') @Plugin.command('about') def command_about(self, event): embed = MessageEmbed() embed.set_author(name='RoPolice', icon_url=self.client.state.me.avatar_url, url='https://www.youtube.com/watch?v=5wFDWP5JwSM') embed.description = BOT_INFO embed.add_field(name='Servers', value=str(Guild.select().count()), inline=True) embed.add_field(name='Uptime', value=humanize.naturaldelta(datetime.utcnow() - self.startup), inline=True) event.msg.reply(embed=embed) @Plugin.command('uptime', level=-1) def command_uptime(self, event): event.msg.reply('RoPolice was started {} ago'.format( humanize.naturaldelta(datetime.utcnow() - self.startup))) @Plugin.command('source', '<command>', level=-1) def command_source(self, event, command=None): for cmd in self.bot.commands: if command.lower() in cmd.triggers: break else: event.msg.reply(u"Couldn't find command for `{}`".format( S(command, escape_codeblocks=True))) return code = cmd.func.__code__ lines, firstlineno = inspect.getsourcelines(code) event.msg.reply( '<https://github.com/Lymia/rowboat/blob/master/{}#L{}-L{}>'.format( code.co_filename, firstlineno, firstlineno + len(lines))) @Plugin.command('sync-bans', group='control', level=-1) def control_sync_bans(self, event): guilds = list(Guild.select().where(Guild.enabled == 1)) msg = event.msg.reply(':timer: pls wait while I sync...') for guild in guilds: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) msg.edit('<:{}> synced {} guilds'.format(GREEN_TICK_EMOJI, len(guilds))) @Plugin.command('reconnect', group='control', level=-1) def control_reconnect(self, event): event.msg.reply('Ok, closing connection') self.client.gw.ws.close() @Plugin.command('invite', '<guild:snowflake>', group='guilds', level=-1) def guild_join(self, event, guild): guild = self.state.guilds.get(guild) if not guild: return event.msg.reply( ':no_entry_sign: invalid or unknown guild ID') msg = event.msg.reply( u'Ok, hold on while I get you setup with an invite link to {}'. format(guild.name, )) general_channel = guild.channels.itervalues().next() try: invite = general_channel.create_invite( max_age=300, max_uses=1, unique=True, ) except: return msg.edit( u':no_entry_sign: Hmmm, something went wrong creating an invite for {}' .format(guild.name, )) msg.edit(u'Ok, here is a temporary invite for you: {}'.format( invite.code, )) @Plugin.command('wh', '<guild:snowflake>', group='guilds', level=-1) def guild_whitelist(self, event, guild): rdb.sadd(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply('Ok, guild %s is now in the whitelist' % guild) @Plugin.command('unwh', '<guild:snowflake>', group='guilds', level=-1) def guild_unwhitelist(self, event, guild): rdb.srem(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply( 'Ok, I\'ve made sure guild %s is no longer in the whitelist' % guild) @Plugin.command('disable', '<plugin:str>', group='plugins', level=-1) def plugin_disable(self, event, plugin): plugin = self.bot.plugins.get(plugin) if not plugin: return event.msg.reply( 'Hmmm, it appears that plugin doesn\'t exist!?') self.bot.rmv_plugin(plugin.__class__) event.msg.reply('Ok, that plugin has been disabled and unloaded')
class CorePlugin(Plugin): def load(self, ctx): init_db(ENV) self.startup = ctx.get('startup', datetime.utcnow()) self.guilds = ctx.get('guilds', {}) self.guild_sync = [] self.guild_sync_debounces = {} self.emitter = Emitter() super(CorePlugin, self).load(ctx) # Overwrite the main bot instances plugin loader so we can magicfy events self.bot.add_plugin = self.our_add_plugin if ENV != 'prod': self.spawn(self.wait_for_plugin_changes) self.global_config = None with open('config.yaml', 'r') as f: self.global_config = safe_load(f) self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) def spawn_wait_for_actions(self, *args, **kwargs): self._wait_for_actions_greenlet = self.spawn(self.wait_for_actions) self._wait_for_actions_greenlet.link_exception( self.spawn_wait_for_actions) def our_add_plugin(self, cls, *args, **kwargs): if getattr(cls, 'global_plugin', False): Bot.add_plugin(self.bot, cls, *args, **kwargs) return inst = cls(self.bot, None) inst.register_trigger('command', 'pre', functools.partial(self.on_pre, inst)) inst.register_trigger('listener', 'pre', functools.partial(self.on_pre, inst)) Bot.add_plugin(self.bot, inst, *args, **kwargs) def wait_for_plugin_changes(self): import gevent_inotifyx as inotify fd = inotify.init() inotify.add_watch(fd, 'rowboat/plugins/', inotify.IN_MODIFY) while True: events = inotify.get_events(fd) for event in events: # Can't reload core.py sadly if event.name.startswith('core.py'): continue plugin_name = '{}Plugin'.format( event.name.split('.', 1)[0].title()) plugin = next((v for k, v in self.bot.plugins.items() if k.lower() == plugin_name.lower()), None) if plugin: self.log.info('Detected change in %s, reloading...', plugin_name) try: plugin.reload() except Exception: self.log.exception('Failed to reload: ') def wait_for_actions(self): ps = rdb.pubsub() ps.subscribe('actions') for item in ps.listen(): if item['type'] != 'message': continue data = json.loads(item['data']) if data['type'] == 'GUILD_UPDATE' and data['id'] in self.guilds: with self.send_control_message() as embed: embed.title = u'Reloaded config for {}'.format( self.guilds[data['id']].name) self.log.info(u'Reloading guild %s', self.guilds[data['id']].name) # Refresh config, mostly to validate try: config = self.guilds[data['id']].get_config(refresh=True) # Reload the guild entirely self.guilds[data['id']] = Guild.with_id(data['id']) # Update guild access self.update_rowboat_guild_access() # Finally, emit the event self.emitter.emit('GUILD_CONFIG_UPDATE', self.guilds[data['id']], config) except: self.log.exception(u'Failed to reload config for guild %s', self.guilds[data['id']].name) continue elif data['type'] == 'RESTART': self.log.info('Restart requested, signaling parent') os.kill(os.getppid(), signal.SIGUSR1) elif data['type'] == 'GUILD_DELETE': name = self.guilds[data['id']].name if self.guilds.has_key( data['id']) else Guild.with_id(data['id']).name with self.send_control_message() as embed: embed.color = 0xff6961 embed.title = u'Guild Force Deleted {}'.format(name, ) try: self.log.info(u'Leaving guild %s', name) self.bot.client.api.users_me_guilds_delete( guild=data['id']) except: self.log.info(u'Cannot leave guild %s, bot not in guild', name) finally: self.log.info(u'Disabling guild %s', name) Guild.update(enabled=False).where( Guild.guild_id == data['id']).execute() self.log.info(u'Unwhilelisting guild %s', name) rdb.srem(GUILDS_WAITING_SETUP_KEY, str(data['id'])) def unload(self, ctx): ctx['guilds'] = self.guilds ctx['startup'] = self.startup super(CorePlugin, self).unload(ctx) def update_rowboat_guild_access(self): # if ROWBOAT_GUILD_ID not in self.state.guilds or ENV != 'prod': if ROWBOAT_GUILD_ID not in self.state.guilds or ENV not in ('prod', 'docker'): return rb_guild = self.state.guilds.get(ROWBOAT_GUILD_ID) if not rb_guild: return self.log.info('Updating Jetski guild access') guilds = Guild.select(Guild.guild_id, Guild.config).where( (Guild.enabled == 1)) users_who_should_have_access = set() for guild in guilds: if 'web' not in guild.config: continue for user_id in guild.config['web'].keys(): try: users_who_should_have_access.add(int(user_id)) except: self.log.warning('Guild %s has invalid user ACLs: %s', guild.guild_id, guild.config['web']) # TODO: sharding users_who_have_access = { i.id for i in rb_guild.members.values() if ROWBOAT_USER_ROLE_ID in i.roles } remove_access = set(users_who_have_access) - set( users_who_should_have_access) add_access = set(users_who_should_have_access) - set( users_who_have_access) for user_id in remove_access: member = rb_guild.members.get(user_id) if not member: continue member.remove_role(ROWBOAT_USER_ROLE_ID) for user_id in add_access: member = rb_guild.members.get(user_id) if not member: continue member.add_role(ROWBOAT_USER_ROLE_ID) def on_pre(self, plugin, func, event, args, kwargs): """ This function handles dynamically dispatching and modifying events based on a specific guilds configuration. It is called before any handler of either commands or listeners. """ if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None if guild_id not in self.guilds: if isinstance(event, CommandEvent): if event.command.metadata.get('global_', False): return event elif hasattr(func, 'subscriptions'): if func.subscriptions[0].metadata.get('global_', False): return event return if hasattr(plugin, 'WHITELIST_FLAG'): if not int( plugin.WHITELIST_FLAG) in self.guilds[guild_id].whitelist: return event.base_config = self.guilds[guild_id].get_config() if not event.base_config: return plugin_name = plugin.name.lower().replace('plugin', '') if not getattr(event.base_config.plugins, plugin_name, None): return self._attach_local_event_data(event, plugin_name, guild_id) return event def get_config(self, guild_id, *args, **kwargs): # Externally Used return self.guilds[guild_id].get_config(*args, **kwargs) def get_guild(self, guild_id): # Externally Used return self.guilds[guild_id] def _attach_local_event_data(self, event, plugin_name, guild_id): if not hasattr(event, 'config'): event.config = LocalProxy() if not hasattr(event, 'rowboat_guild'): event.rowboat_guild = LocalProxy() event.config.set(getattr(event.base_config.plugins, plugin_name)) event.rowboat_guild.set(self.guilds[guild_id]) @Plugin.schedule(290, init=False) def update_guild_bans(self): to_update = [ guild for guild in Guild.select().where(( Guild.last_ban_sync < (datetime.utcnow() - timedelta(days=1))) | (Guild.last_ban_sync >> None)) if guild.guild_id in self.client.state.guilds ] # Update 10 at a time for guild in to_update[:10]: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) @Plugin.listen('GuildUpdate') def on_guild_update(self, event): self.log.info('Got guild update for guild %s (%s)', event.guild.id, event.guild.channels) @Plugin.listen('GuildMembersChunk') def on_guild_members_chunk(self, event): self.log.info('Got members chunk ({}) for guild {}'.format( len(event.members), event.guild_id)) # for user in event.members: # if user.user.username is UNSET: # # self.log.info('User chunk {} for guild {} has empty values, attempting to patch'.format(user.user.id, event.guild_id)) # data = self.bot.client.api.http(Routes.USERS_GET, dict(user=user.user.id)) # disco_user = DiscoUser.create(self.bot.client.api.client, data.json()) # self.bot.client.state.users[user.id].inplace_update(disco_user) @Plugin.listen('GuildBanAdd') def on_guild_ban_add(self, event): GuildBan.ensure(self.client.state.guilds.get(event.guild_id), event.user) @Plugin.listen('GuildBanRemove') def on_guild_ban_remove(self, event): GuildBan.delete().where((GuildBan.user_id == event.user.id) & (GuildBan.guild_id == event.guild_id)) @contextlib.contextmanager def send_control_message(self): embed = MessageEmbed() embed.set_footer(text='Jetski {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_CONTROL_CHANNEL, embed=embed) except: self.log.exception('Failed to send control message:') return @contextlib.contextmanager def send_spam_control_message(self): embed = MessageEmbed() embed.set_footer(text='Jetski {}'.format('Production' if ENV == 'prod' else 'Testing')) embed.timestamp = datetime.utcnow().isoformat() embed.color = 0x779ecb try: yield embed self.bot.client.api.channels_messages_create( ROWBOAT_SPAM_CONTROL_CHANNEL, embed=embed) except: self.log.exception('Failed to send spam control message:') return @Plugin.listen('Resumed') def on_resumed(self, event): Notification.dispatch( Notification.Types.RESUME, env=ENV, ) with self.send_control_message() as embed: embed.title = 'Resumed' embed.color = 0xffb347 embed.add_field(name='Replayed Events', value=str(self.client.gw.replayed_events)) @Plugin.listen('Ready', priority=Priority.BEFORE) def on_ready(self, event): reconnects = self.client.gw.reconnects self.log.info('Started session %s', event.session_id) Notification.dispatch( Notification.Types.CONNECT, env=ENV, ) with self.send_control_message() as embed: if reconnects: embed.title = 'Reconnected' embed.color = 0xffb347 else: embed.title = 'Connected' embed.color = 0x77dd77 embed.add_field(name='Gateway Version', value='v{}'.format(event.version), inline=False) embed.add_field(name='Session ID', value=event.session_id, inline=False) @Plugin.schedule(45, init=False, repeat=False) def update_guild_syncs(self): if len(self.guild_sync) == 0: return guilds = [i for i in self.guild_sync] # hacky deepcopy basically for guild_id in guilds: if guild_id in self.guild_sync_debounces: if self.guild_sync_debounces.get(guild_id) > time.time(): self.guild_sync_debounces.pop(guild_id) guilds.remove(guild_id) else: self.guild_sync_debounces[guild_id] = time.time() + 300 self.guild_sync.remove(guild_id) self.log.info('Requesting Guild Member States for {} guilds'.format( len(guilds))) self.bot.client.gw.request_guild_members(guild_id_or_ids=guilds) @Plugin.listen('GuildCreate', priority=Priority.BEFORE, conditional=lambda e: not e.created) def on_guild_create(self, event): try: guild = Guild.with_id(event.id) except Guild.DoesNotExist: # If the guild is not awaiting setup, leave it now if not rdb.sismember(GUILDS_WAITING_SETUP_KEY, str( event.id)) and event.id != ROWBOAT_GUILD_ID: self.log.warning( 'Leaving guild %s (%s), not within setup list', event.id, event.name) event.guild.leave() return if not guild.enabled: if rdb.sismember(GUILDS_WAITING_SETUP_KEY, str(event.id)): guild.enabled = True guild.save() else: return config = guild.get_config() if not config: return # Ensure we're updated # self.log.info('Syncing guild %s', event.guild.id) # guild.sync(event.guild) self.log.info('Adding guild {} to sync list'.format(event.id)) self.guild_sync.append(event.id) self.guilds[event.id] = guild if config.nickname: def set_nickname(): m = event.members.select_one(id=self.state.me.id) if m and m.nick != config.nickname: try: m.set_nickname(config.nickname) except APIException as e: self.log.warning( 'Failed to set nickname for guild %s (%s)', event.guild, e.content) self.spawn_later(5, set_nickname) def get_level(self, guild, user): config = (guild.id in self.guilds and self.guilds.get(guild.id).get_config()) user_level = 0 if config: member = guild.get_member(user) if not member: return user_level for oid in member.roles: if oid in config.levels and config.levels[oid] > user_level: user_level = config.levels[oid] # User ID overrides should override all others if member.id in config.levels: user_level = config.levels[member.id] return user_level @Plugin.listen('MessageCreate') def on_message_create(self, event, is_tag=False): """ This monstrosity of a function handles the parsing and dispatching of commands. """ # Ignore messages sent by bots if event.message.author.bot: return if rdb.sismember('ignored_channels', event.message.channel_id): return # If this is message for a guild, grab the guild object if hasattr(event, 'guild') and event.guild: guild_id = event.guild.id elif hasattr(event, 'guild_id') and event.guild_id: guild_id = event.guild_id else: guild_id = None guild = self.guilds.get(event.guild.id) if guild_id else None config = guild and guild.get_config() cc = config.commands if config else None # If the guild has configuration, use that (otherwise use defaults) if config and config.commands: commands = list( self.bot.get_commands_for_message(config.commands.mention, {}, config.commands.prefix, event.message)) elif guild_id: # Otherwise, default to requiring mentions commands = list( self.bot.get_commands_for_message(True, {}, '', event.message)) else: # if ENV != 'prod': if ENV not in ('prod', 'docker'): if not event.message.content.startswith(ENV + '!'): return event.message.content = event.message.content[len(ENV) + 1:] # DM's just use the commands (no prefix/mention) commands = list( self.bot.get_commands_for_message(False, {}, '', event.message)) # if no command, attempt to run as a tag if not commands: if is_tag: return if not event.guild or not self.bot.plugins.get('TagsPlugin'): return if not config or not config.plugins or not config.plugins.tags: return prefixes = [] if cc.prefix: prefixes.append(cc.prefix) if cc.mention: prefixes.append('{} '.format(self.state.me.mention)) if not prefixes: return tag_re = re.compile('^({})(.+)'.format( re.escape('|'.join(prefixes)))) m = tag_re.match(event.message.content) if not m: return sqlplugin = self.bot.plugins.get('SQLPlugin') if sqlplugin: sqlplugin.tag_messages.append(event.message.id) event.message.content = u'{}tags show {}'.format( m.group(1), m.group(2)) return self.on_message_create(event, True) event.user_level = self.get_level(event.guild, event.author) if event.guild else 0 # Grab whether this user is a global admin # TODO: cache this global_admin = rdb.sismember('global_admins', event.author.id) # Iterate over commands and find a match for command, match in commands: if command.level == -1 and not global_admin: continue level = command.level if guild and not config and command.triggers[0] != 'setup': continue elif config and config.commands and command.plugin != self: overrides = {} for obj in config.commands.get_command_override(command): overrides.update(obj) if overrides.get('disabled'): continue level = overrides.get('level', level) if not global_admin and event.user_level < level: continue with timed('rowboat.command.duration', tags={ 'plugin': command.plugin.name, 'command': command.name }): try: command_event = CommandEvent(command, event.message, match) command_event.user_level = event.user_level command.plugin.execute(command_event) except CommandResponse as e: if is_tag: return event.reply(e.response) except: tracked = Command.track(event, command, exception=True) self.log.exception('Command error:') with self.send_control_message() as embed: embed.title = u'Command Error: {}'.format(command.name) embed.color = 0xff6961 embed.add_field(name='Author', value='({}) `{}`'.format( event.author, event.author.id), inline=True) embed.add_field(name='Channel', value='({}) `{}`'.format( event.channel.name, event.channel.id), inline=True) embed.description = '```{}```'.format(u'\n'.join( tracked.traceback.split('\n')[-8:])) return event.reply( '<:{}> something went wrong, perhaps try again later'. format(RED_TICK_EMOJI)) Command.track(event, command) # Dispatch the command used modlog event if config: modlog_config = getattr(config.plugins, 'modlog', None) if not modlog_config: return if is_tag: # Yes, I know, this is ugly but I don't have better event.content = event.content.replace('tags show ', '', 1) self._attach_local_event_data(event, 'modlog', event.guild.id) plugin = self.bot.plugins.get('ModLogPlugin') if plugin: plugin.log_action(Actions.COMMAND_USED, event) return @Plugin.command('setup') def command_setup(self, event): if not event.guild: return event.msg.reply( ':warning: this command can only be used in servers') # Make sure we're not already setup if event.guild.id in self.guilds: return event.msg.reply(':warning: this server is already setup') global_admin = rdb.sismember('global_admins', event.author.id) # Make sure this is the owner of the server if not global_admin: if not event.guild.owner_id == event.author.id: return event.msg.reply( ':warning: only the server owner can setup Jetski') # Make sure we have admin perms m = event.guild.members.select_one(id=self.state.me.id) if not m.permissions.administrator and not global_admin: return event.msg.reply( ':warning: bot must have the Administrator permission') guild = Guild.setup(event.guild) rdb.srem(GUILDS_WAITING_SETUP_KEY, str(event.guild.id)) self.guilds[event.guild.id] = guild event.msg.reply(':ok_hand: successfully loaded configuration') @Plugin.command('nuke', '<user:snowflake> <reason:str...>', level=-1) def nuke(self, event, user, reason): contents = [] for gid, guild in self.guilds.items(): guild = self.state.guilds[gid] perms = guild.get_permissions(self.state.me) if not perms.ban_members and not perms.administrator: contents.append(u':x: {} - No Permissions'.format(guild.name)) continue try: Infraction.ban(self.bot.plugins.get('AdminPlugin'), event, user, reason, guild=guild) except: contents.append(u':x: {} - Unknown Error'.format(guild.name)) self.log.exception('Failed to force ban %s in %s', user, gid) contents.append( u':white_check_mark: {} - :regional_indicator_f:'.format( guild.name)) event.msg.reply('Results:\n' + '\n'.join(contents)) @Plugin.command('about') def command_about(self, event): embed = MessageEmbed() embed.color = 0x7289da embed.set_author(name='Jetski', icon_url=self.client.state.me.avatar_url, url='https://jetski.ga/') embed.description = BOT_INFO embed.add_field(name='Servers', value=str(Guild.select().count()), inline=True) embed.add_field(name='Uptime', value=humanize_duration(datetime.utcnow() - self.startup), inline=True) event.msg.reply(embed=embed) @Plugin.command('uptime', level=-1) def command_uptime(self, event): event.msg.reply('Jetski was started {} ago.'.format( humanize_duration(datetime.utcnow() - self.startup))) @Plugin.command('source', '<command>', level=-1) def command_source(self, event, command=None): for cmd in self.bot.commands: if command.lower() in cmd.triggers: break else: event.msg.reply(u"Couldn't find command for `{}`".format( S(command, escape_codeblocks=True))) return code = cmd.func.__code__ lines, firstlineno = inspect.getsourcelines(code) event.msg.reply( '<https://github.com/ThaTiemsz/jetski/blob/master/{}#L{}-{}>'. format(code.co_filename, firstlineno, firstlineno + len(lines))) @Plugin.command('eval', level=-1) def command_eval(self, event): ctx = { 'bot': self.bot, 'client': self.bot.client, 'state': self.bot.client.state, 'event': event, 'msg': event.msg, 'guild': event.msg.guild, 'channel': event.msg.channel, 'author': event.msg.author } # Mulitline eval src = event.codeblock if src.count('\n'): lines = filter(bool, src.split('\n')) if lines[-1] and 'return' not in lines[-1]: lines[-1] = 'return ' + lines[-1] lines = '\n'.join(' ' + i for i in lines) code = 'def f():\n{}\nx = f()'.format(lines) local = {} try: exec compile(code, '<eval>', 'exec') in ctx, local except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return result = pprint.pformat(local['x']) else: try: result = str(eval(src, ctx)) except Exception as e: event.msg.reply( PY_CODE_BLOCK.format(type(e).__name__ + ': ' + str(e))) return if len(result) > 1990: event.msg.reply('', attachments=[('result.txt', result)]) else: event.msg.reply(PY_CODE_BLOCK.format(result)) @Plugin.command('sync-bans', group='control', level=-1) def control_sync_bans(self, event): guilds = list(Guild.select().where(Guild.enabled == 1)) msg = event.msg.reply(':timer: pls wait while I sync...') for guild in guilds: guild.sync_bans(self.client.state.guilds.get(guild.guild_id)) msg.edit('<:{}> synced {} guilds'.format(GREEN_TICK_EMOJI, len(guilds))) @Plugin.command('reconnect', group='control', level=-1) def control_reconnect(self, event): event.msg.reply('Ok, closing connection') self.client.gw.ws.close() @Plugin.command('invite', '<guild:snowflake>', group='guilds', level=-1) def guild_join(self, event, guild): guild = self.state.guilds.get(guild) if not guild: return event.msg.reply( ':no_entry_sign: invalid or unknown guild ID') msg = event.msg.reply( u'Ok, hold on while I get you setup with an invite link to {}'. format(guild.name, )) general_channel = guild.channels[guild.id] try: invite = general_channel.create_invite( max_age=300, max_uses=1, unique=True, ) except: return msg.edit( u':no_entry_sign: Hmmm, something went wrong creating an invite for {}' .format(guild.name, )) msg.edit(u'Ok, here is a temporary invite for you: {}'.format( invite.code, )) @Plugin.command('wh', '<guild:snowflake>', group='guilds', level=-1) def guild_whitelist(self, event, guild): rdb.sadd(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply('Ok, guild %s is now in the whitelist' % guild) @Plugin.command('unwh', '<guild:snowflake>', group='guilds', level=-1) def guild_unwhitelist(self, event, guild): rdb.srem(GUILDS_WAITING_SETUP_KEY, str(guild)) event.msg.reply( 'Ok, I\'ve made sure guild %s is no longer in the whitelist' % guild) @Plugin.command('wh-add', '<guild:snowflake> <flag:str>', group='guilds', level=-1) def add_whitelist(self, event, guild, flag): flag = Guild.WhitelistFlags.get(flag) if not flag: raise CommandFail('invalid flag') try: guild = Guild.get(guild_id=guild) except Guild.DoesNotExist: raise CommandFail('no guild exists with that id') if guild.is_whitelisted(flag): raise CommandFail('this guild already has this flag') guild.whitelist.append(int(flag)) guild.save() guild.emit('GUILD_UPDATE') event.msg.reply('Ok, added flag `{}` to guild {}'.format( str(flag), guild.guild_id)) @Plugin.command('wh-rmv', '<guild:snowflake> <flag:str>', group='guilds', level=-1) def rmv_whitelist(self, event, guild, flag): flag = Guild.WhitelistFlags.get(flag) if not flag: raise CommandFail('invalid flag') try: guild = Guild.get(guild_id=guild) except Guild.DoesNotExist: raise CommandFail('no guild exists with that id') if not guild.is_whitelisted(flag): raise CommandFail('this guild doesn\'t have this flag') guild.whitelist.remove(int(flag)) guild.save() guild.emit('GUILD_UPDATE') event.msg.reply('Ok, removed flag `{}` from guild {}'.format( str(flag), guild.guild_id)) @Plugin.command('plugins', aliases=['pl', 'plugin'], context={'mode': 'list'}, level=-1) @Plugin.command('list', group='plugins', aliases=['ls'], context={'mode': 'list'}, level=-1) @Plugin.command('load', '<plugin:str>', group='plugins', aliases=['enable'], context={'mode': 'load'}, level=-1) @Plugin.command('unload', '<plugin:str>', group='plugins', aliases=['disable'], context={'mode': 'unload'}, level=-1) @Plugin.command('reload', '<plugin:str>', group='plugins', aliases=['restart'], context={'mode': 'reload'}, level=-1) def plugin_manager(self, event, plugin=None, mode='list'): if mode == 'list': embed = MessageEmbed() embed.color = 0x7289da embed.set_author(name='Loaded Plugins ({})'.format( len(self.bot.plugins)), icon_url=self.state.me.avatar_url) embed.description = '```md\n{}```'.format('\n'.join( '- {}'.format(key) for key in self.bot.plugins)) embed.set_footer( text= 'Use file name for load, registered name for unload/reload.') return event.msg.reply('', embed=embed) pl = self.bot.plugins.get(plugin) if mode == 'load': if pl: return event.msg.reply( '<:{}> {} plugin is already loaded.'.format( RED_TICK_EMOJI, pl.name)) try: self.bot.add_plugin_module('rowboat.plugins.{}'.format(plugin)) except: return event.msg.reply( '<:{}> Failed to load {} plugin.'.format( RED_TICK_EMOJI, plugin)) return event.msg.reply( ':ok_hand: Loaded {} plugin.'.format(plugin)) if not pl: return event.msg.reply( '<:{}> Could not find this plugin.'.format(plugin)) if mode == 'unload': self.bot.rmv_plugin(pl.__class__) self.log.info('Unloaded {} plugin'.format(pl.name)) return event.msg.reply(':ok_hand: Unloaded {} plugin.'.format( pl.name)) if mode == 'reload': self.bot.reload_plugin(pl.__class__) self.log.info('Reloaded {} plugin'.format(pl.name)) return event.msg.reply(':ok_hand: Reloaded {} plugin.'.format( pl.name))
class VoiceClient(LoggingClass): VOICE_GATEWAY_VERSION = 4 SUPPORTED_MODES = { 'xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305', } def __init__(self, client, server_id, is_dm=False, encoder=None, max_reconnects=5): super(VoiceClient, self).__init__() self.client = client self.server_id = server_id self.channel_id = None self.is_dm = is_dm self.encoder = encoder or JSONEncoder self.max_reconnects = max_reconnects self.video_enabled = False # Set the VoiceClient in the state's voice clients self.client.state.voice_clients[self.server_id] = self # Bind to some WS packets self.packets = Emitter() self.packets.on(VoiceOPCode.HELLO, self.on_voice_hello) self.packets.on(VoiceOPCode.READY, self.on_voice_ready) self.packets.on(VoiceOPCode.RESUMED, self.on_voice_resumed) self.packets.on(VoiceOPCode.SESSION_DESCRIPTION, self.on_voice_sdp) self.packets.on(VoiceOPCode.SPEAKING, self.on_voice_speaking) self.packets.on(VoiceOPCode.CLIENT_CONNECT, self.on_voice_client_connect) self.packets.on(VoiceOPCode.CLIENT_DISCONNECT, self.on_voice_client_disconnect) self.packets.on(VoiceOPCode.CODECS, self.on_voice_codecs) # State + state change emitter self.state = VoiceState.DISCONNECTED self.state_emitter = Emitter() # Connection metadata self.token = None self.endpoint = None self.ssrc = None self.ip = None self.port = None self.mode = None self.udp = None self.audio_codec = None self.video_codec = None self.transport_id = None # Websocket connection self.ws = None self._session_id = self.client.gw.session_id self._reconnects = 0 self._heartbeat_task = None self._identified = False # SSRCs self.audio_ssrcs = {} def __repr__(self): return u'<VoiceClient {}>'.format(self.server_id) @cached_property def guild(self): return self.client.state.guilds.get( self.server_id) if not self.is_dm else None @cached_property def channel(self): return self.client.state.channels.get(self.channel_id) @property def user_id(self): return self.client.state.me.id @property def ssrc_audio(self): return self.ssrc @property def ssrc_video(self): return self.ssrc + 1 @property def ssrc_rtx(self): return self.ssrc + 2 @property def ssrc_rtcp(self): return self.ssrc + 3 def set_state(self, state): self.log.debug('[%s] state %s -> %s', self, self.state, state) prev_state = self.state self.state = state self.state_emitter.emit(state, prev_state) def set_endpoint(self, endpoint): endpoint = endpoint.split(':', 1)[0] if self.endpoint == endpoint: return self.log.info( '[%s] Set endpoint from VOICE_SERVER_UPDATE (state = %s / endpoint = %s)', self, self.state, endpoint) self.endpoint = endpoint if self.ws and self.ws.sock and self.ws.sock.connected: self.ws.close() self.ws = None self._identified = False def set_token(self, token): if self.token == token: return self.token = token if not self._identified: self._connect_and_run() def _connect_and_run(self): self.ws = Websocket('wss://' + self.endpoint + '/?v={}'.format(self.VOICE_GATEWAY_VERSION)) self.ws.emitter.on('on_open', self.on_open) self.ws.emitter.on('on_error', self.on_error) self.ws.emitter.on('on_close', self.on_close) self.ws.emitter.on('on_message', self.on_message) self.ws.run_forever() def _heartbeat(self, interval): while True: self.send(VoiceOPCode.HEARTBEAT, time.time()) gevent.sleep(interval / 1000) def set_speaking(self, voice=False, soundshare=False, priority=False, delay=0): value = SpeakingFlags.NONE.value if voice: value |= SpeakingFlags.VOICE.value if soundshare: value |= SpeakingFlags.SOUNDSHARE.value if priority: value |= SpeakingFlags.PRIORITY.value self.send(VoiceOPCode.SPEAKING, { 'speaking': value, 'delay': delay, 'ssrc': self.ssrc, }) def set_voice_state(self, channel_id, mute=False, deaf=False, video=False): self.client.gw.send( OPCode.VOICE_STATE_UPDATE, { 'self_mute': bool(mute), 'self_deaf': bool(deaf), 'self_video': bool(video), 'guild_id': None if self.is_dm else self.server_id, 'channel_id': channel_id, }) def send(self, op, data): if self.ws and self.ws.sock and self.ws.sock.connected: self.log.debug('[%s] sending OP %s (data = %s)', self, op, data) self.ws.send(self.encoder.encode({ 'op': op.value, 'd': data, }), self.encoder.OPCODE) else: self.log.debug( '[%s] dropping because ws is closed OP %s (data = %s)', self, op, data) def on_voice_client_connect(self, data): user_id = int(data['user_id']) self.audio_ssrcs[data['audio_ssrc']] = user_id # ignore data['voice_ssrc'] for now def on_voice_client_disconnect(self, data): user_id = int(data['user_id']) for ssrc in self.audio_ssrcs.keys(): if self.audio_ssrcs[ssrc] == user_id: del self.audio_ssrcs[ssrc] break def on_voice_codecs(self, data): self.audio_codec = data['audio_codec'] self.video_codec = data['video_codec'] self.transport_id = data['media_session_id'] # Set the UDP's RTP Audio Header's Payload Type self.udp.set_audio_codec(data['audio_codec']) def on_voice_hello(self, data): self.log.info( '[%s] Received Voice HELLO payload, starting heartbeater', self) self._heartbeat_task = gevent.spawn(self._heartbeat, data['heartbeat_interval']) self.set_state(VoiceState.AUTHENTICATED) def on_voice_ready(self, data): self.log.info( '[%s] Received Voice READY payload, attempting to negotiate voice connection w/ remote', self) self.set_state(VoiceState.CONNECTING) self.ssrc = data['ssrc'] self.ip = data['ip'] self.port = data['port'] self._identified = True for mode in self.SUPPORTED_MODES: if mode in data['modes']: self.mode = mode self.log.debug('[%s] Selected mode %s', self, mode) break else: raise Exception('Failed to find a supported voice mode') self.log.debug('[%s] Attempting IP discovery over UDP to %s:%s', self, self.ip, self.port) self.udp = UDPVoiceClient(self) ip, port = self.udp.connect(self.ip, self.port) if not ip: self.log.error( 'Failed to discover our IP, perhaps a NAT or firewall is f*****g us' ) self.disconnect() return codecs = [] # Sending discord our available codecs and rtp payload type for it for idx, codec in enumerate(AudioCodecs): codecs.append({ 'name': codec, 'type': 'audio', 'priority': (idx + 1) * 1000, 'payload_type': RTPPayloadTypes.get(codec).value, }) self.log.debug( '[%s] IP discovery completed (ip = %s, port = %s), sending SELECT_PROTOCOL', self, ip, port) self.send( VoiceOPCode.SELECT_PROTOCOL, { 'protocol': 'udp', 'data': { 'port': port, 'address': ip, 'mode': self.mode, }, 'codecs': codecs, }) self.send(VoiceOPCode.CLIENT_CONNECT, { 'audio_ssrc': self.ssrc, 'video_ssrc': 0, 'rtx_ssrc': 0, }) def on_voice_resumed(self, data): self.log.info('[%s] Received resumed', self) self.set_state(VoiceState.CONNECTED) def on_voice_sdp(self, sdp): self.log.info( '[%s] Received session description, connection completed', self) self.mode = sdp['mode'] self.audio_codec = sdp['audio_codec'] self.video_codec = sdp['video_codec'] self.transport_id = sdp['media_session_id'] # Set the UDP's RTP Audio Header's Payload Type self.udp.set_audio_codec(sdp['audio_codec']) # Create a secret box for encryption/decryption self.udp.setup_encryption(bytes(bytearray(sdp['secret_key']))) self.set_state(VoiceState.CONNECTED) def on_voice_speaking(self, data): user_id = int(data['user_id']) self.audio_ssrcs[data['ssrc']] = user_id # Maybe rename speaking to voice in future payload = VoiceSpeaking( client=self, user_id=user_id, speaking=bool(data['speaking'] & SpeakingFlags.VOICE.value), soundshare=bool(data['speaking'] & SpeakingFlags.SOUNDSHARE.value), priority=bool(data['speaking'] & SpeakingFlags.PRIORITY.value), ) self.client.gw.events.emit('VoiceSpeaking', payload) def on_message(self, msg): try: data = self.encoder.decode(msg) self.packets.emit(VoiceOPCode[data['op']], data['d']) except Exception: self.log.exception('Failed to parse voice gateway message: ') def on_error(self, err): self.log.error('[%s] Voice websocket error: %s', self, err) def on_open(self): if self._identified: self.send( VoiceOPCode.RESUME, { 'server_id': self.server_id, 'session_id': self._session_id, 'token': self.token, }) else: self.send( VoiceOPCode.IDENTIFY, { 'server_id': self.server_id, 'user_id': self.user_id, 'session_id': self._session_id, 'token': self.token, 'video': self.video_enabled, }) def on_close(self, code, reason): self.log.warning('[%s] Voice websocket closed: [%s] %s (%s)', self, code, reason, self._reconnects) if self._heartbeat_task: self._heartbeat_task.kill() self._heartbeat_task = None self.ws = None # If we killed the connection, don't try resuming if self.state == VoiceState.DISCONNECTED: return self.log.info('[%s] Attempting Websocket Resumption', self) self.set_state(VoiceState.RECONNECTING) # Check if code is not None, was not from us if code is not None: self._reconnects += 1 if self.max_reconnects and self._reconnects > self.max_reconnects: raise VoiceException( 'Failed to reconnect after {} attempts, giving up'.format( self.max_reconnects), self) # Don't resume for these error codes: if 4000 <= code <= 4016: self._identified = False if self.udp and self.udp.connected: self.udp.disconnect() wait_time = 5 else: wait_time = 1 self.log.info('[%s] Will attempt to %s after %s seconds', self, 'resume' if self._identified else 'reconnect', wait_time) gevent.sleep(wait_time) self._connect_and_run() def connect(self, channel_id, timeout=10, **kwargs): if self.is_dm: channel_id = self.server_id if not channel_id: raise VoiceException( '[{}] cannot connect to an empty channel id'.format(self)) if self.channel_id == channel_id: if self.state == VoiceState.CONNECTED: self.log.debug('[%s] Already connected to %s, returning', self, self.channel) return self else: if self.state == VoiceState.CONNECTED: self.log.debug('[%s] Moving to channel %s', self, channel_id) else: self.log.debug('[%s] Attempting connection to channel id %s', self, channel_id) self.set_state(VoiceState.AWAITING_ENDPOINT) self.set_voice_state(channel_id, **kwargs) if not self.state_emitter.once(VoiceState.CONNECTED, timeout=timeout): self.disconnect() raise VoiceException('Failed to connect to voice', self) else: return self def disconnect(self): if self.state == VoiceState.DISCONNECTED: return self.log.debug('[%s] disconnect called', self) self.set_state(VoiceState.DISCONNECTED) del self.client.state.voice_clients[self.server_id] if self._heartbeat_task: self._heartbeat_task.kill() self._heartbeat_task = None if self.ws and self.ws.sock and self.ws.sock.connected: self.ws.close() self.ws = None if self.udp and self.udp.connected: self.udp.disconnect() if self.channel_id: self.set_voice_state(None) self.client.gw.events.emit('VoiceDisconnect', self) def send_frame(self, *args, **kwargs): self.udp.send_frame(*args, **kwargs) def increment_timestamp(self, *args, **kwargs): self.udp.increment_timestamp(*args, **kwargs)