Ejemplo n.º 1
0
class PyLinkDiscordProtocol(PyLinkNetworkCoreWithUtils):
    S2S_BUFSIZE = 0

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if 'token' not in self.serverdata:
            raise ProtocolError("No API token defined under server settings")

        client_config = ClientConfig({'token': self.serverdata['token'],
                                      'max_reconnects': 0})
        self.client = Client(client_config)

        bot_config = BotConfig()
        self.bot = Bot(self.client, bot_config)

        self.bot_plugin = DiscordBotPlugin(self, self.bot, bot_config)
        self.bot.add_plugin(self.bot_plugin)

        self._children = {}
        self.message_queue = queue.Queue()
        self.webhooks = {}
        self._message_thread = None

    @staticmethod
    def is_nick(s, nicklen=None):
        return True

    def is_channel(self, s):
        """Returns whether the target is a channel."""
        try:
            chan = int(s)
        except (TypeError, ValueError):
            return False
        return chan in self.bot_plugin.state.channels

    def is_server_name(self, s):
        """Returns whether the string given is a valid IRC server name."""
        return s in self.bot_plugin.state.guilds

    def is_internal_client(self, uid):
        """Returns whether the given client is an internal one."""
        if uid == self.bot_plugin.me.id:
            return True
        return super().is_internal_client(uid)

    def get_friendly_name(self, entityid, caller=None):
        """
        Returns the friendly name of a SID (the guild name), UID (the nick), or channel (the name).
        """
        # internal PUID, handle appropriately
        if isinstance(entityid, str) and '@' in entityid:
            if entityid in self.users:
                return self.users[entityid].nick
            elif caller and entityid in caller.users:
                return caller.users[entityid].nick
            else:
                # Probably a server
                return entityid.split('@', 1)[0]

        if self.is_channel(entityid):
            return str(self.bot_plugin.state.channels[entityid])
        elif entityid in self.bot_plugin.state.users:
            return self.bot_plugin.state.users[entityid].username
        elif self.is_server_name(entityid):
            return self.bot_plugin.state.guilds[entityid].name
        else:
            raise KeyError("Unknown entity ID %s" % str(entityid))

    @staticmethod
    def wrap_message(source, target, text):
        """
        STUB: returns the message text wrapped onto multiple lines.
        """
        return [text]

    def _get_webhook(self, channel):
        """
        Returns the webhook saved for the given channel, or try to create one if none exists.
        """
        if channel.id in self.webhooks:  # We've already saved this webhook
            wh = self.webhooks[channel.id]
            log.debug('discord: Using saved webhook %s (%s) for channel %s', wh.id, wh.name, channel)
            return wh

        # Generate a webhook name based off a configurable prefix and the channel ID
        webhook_name = '%s-%d' % (self.serverdata.get('webhook_name') or 'PyLinkRelay', channel.id)

        for wh in channel.get_webhooks():
            if wh.name == webhook_name:  # This hook matches our name
                self.webhooks[channel.id] = wh
                log.info('discord: Using existing webhook %s (%s) for channel %s', wh.id, webhook_name, channel)
                return wh

        # If we didn't find any webhooks, create a new one
        wh = self.webhooks[channel.id] = channel.create_webhook(name=webhook_name)
        log.info('discord: Created new webhook %s (%s) for channel %s', wh.id, wh.name, channel)
        return wh

    def _get_webhook_fields(self, user):
        """
        Returns a dict of Relay substitution fields for the given User object.
        This attempts to find the original user via Relay if the .remote metadata field is set.

        The output includes all keys provided in User.get_fields(), plus the following:
            netname: The full network name of the network 'user' belongs to
            nettag: The short network tag of the network 'user' belongs to
            avatar: The URL to the user's avatar (str), or None if no avatar is specified
        """
        # Try to lookup the remote user data via relay metadata
        if hasattr(user, 'remote'):
            remotenet, remoteuid = user.remote
            try:
                netobj = world.networkobjects[remotenet]
                user = netobj.users[remoteuid]
            except LookupError:
                netobj = user._irc

        fields = user.get_fields()
        fields['netname'] = netobj.get_full_network_name()
        fields['nettag'] = netobj.name

        default_avatar_url = self.serverdata.get('default_avatar_url')
        avatar = None
        # XXX: we'll have a more rigorous matching system later on
        if user.services_account in self.serverdata.get('avatars', {}):
            avatar_url = self.serverdata['avatars'][user.services_account]
            p = urllib.parse.urlparse(avatar_url)
            log.debug('(%s) Got raw avatar URL %s for user %s', self.name, avatar_url, user)

            if p.scheme == 'gravatar' and libgravatar:  # gravatar:[email protected]
                try:
                    g = libgravatar.Gravatar(p.path)
                    log.debug('(%s) Using Gravatar email %s for user %s', self.name, p.path, user)
                    avatar = g.get_image(use_ssl=True)
                except:
                    log.exception('Failed to obtain Gravatar image for user %s/%s', user, p.path)

            elif p.scheme in ('http', 'https'):  # a direct image link
                avatar = avatar_url

            else:
                log.warning('(%s) Unknown avatar URI %s for user %s', self.name, avatar_url, user)
        elif default_avatar_url:
            log.debug('(%s) Avatar not defined for user %s; using default avatar %s', self.name, user, default_avatar_url)
            avatar = default_avatar_url
        else:
            log.debug('(%s) Avatar not defined for user %s; using default webhook avatar', self.name, user)
        fields['avatar'] = avatar
        return fields

    MAX_MESSAGE_SIZE = 2000
    def _message_builder(self):
        """
        Discord message queue handler. Also supports virtual users via webhooks.
        """
        def _send(sender, channel, pylink_target, message_parts):
            """
            Wrapper to send a joined message.
            """
            text = '\n'.join(message_parts)

            # Handle the case when the sender is not the PyLink client (sender != None)
            # For channels, use either virtual webhook users or CLIENTBOT_MESSAGE forwarding (relay_clientbot).
            if sender:
                user_fields = self._get_webhook_fields(sender)

                if channel.guild:  # This message belongs to a channel
                    netobj = self._children[channel.guild.id]

                    # Note: skip webhook sending for messages that contain only spaces, as that fails with
                    # 50006 "Cannot send an empty message" errors
                    if netobj.serverdata.get('use_webhooks') and text.strip():
                        user_format = netobj.serverdata.get('webhook_user_format', "$nick @ $netname")
                        tmpl = string.Template(user_format)
                        webhook_fake_username = tmpl.safe_substitute(self._get_webhook_fields(sender))

                        try:
                            webhook = self._get_webhook(channel)
                            webhook.execute(content=text[:self.MAX_MESSAGE_SIZE], username=webhook_fake_username, avatar_url=user_fields['avatar'])
                        except APIException as e:
                            if e.code == 10015 and channel.id in self.webhooks:
                                log.info("(%s) Invalidating webhook %s for channel %s due to Unknown Webhook error (10015)",
                                         self.name, self.webhooks[channel.id], channel)
                                del self.webhooks[channel.id]
                            elif e.code == 50013:
                                # Prevent spamming errors: disable webhooks we don't have the right permissions
                                log.warning("(%s) Disabling webhooks on guild %s/%s due to insufficient permissions (50013). Rehash to re-enable.",
                                            self.name, channel.guild.id, channel.guild.name)
                                self.serverdata.update(
                                    {'guilds':
                                        {channel.guild.id:
                                            {'use_webhooks': False}
                                        }
                                    })
                            else:
                                log.error("(%s) Caught API exception when sending webhook message to channel %s: %s/%s", self.name, channel, e.response.status_code, e.code)
                            log.debug("(%s) APIException full traceback:", self.name, exc_info=True)

                        except:
                            log.exception("(%s) Failed to send webhook message to channel %s", self.name, channel)
                        else:
                            return

                    for line in message_parts:
                        netobj.call_hooks([sender.uid, 'CLIENTBOT_MESSAGE', {'target': pylink_target, 'text': line}])
                    return
                else:
                    # This is a forwarded PM - prefix the message with its sender info.
                    pm_format = self.serverdata.get('pm_format', "Message from $nick @ $netname: $text")
                    user_fields['text'] = text
                    text = string.Template(pm_format).safe_substitute(user_fields)

            try:
                channel.send_message(text[:self.MAX_MESSAGE_SIZE])
            except Exception as e:
                log.exception("(%s) Could not send message to channel %s (pylink_target=%s)", self.name, channel, pylink_target)

        joined_messages = collections.defaultdict(collections.deque)
        while not self._aborted.is_set():
            try:
                # message is an instance of QueuedMessage (defined in this file)
                message = self.message_queue.get(timeout=BATCH_DELAY)
                message.text = utils.strip_irc_formatting(message.text)

                if not self.serverdata.get('allow_mention_everyone', False):
                    message.text = message.text.replace('@here', '@ here')
                    message.text = message.text.replace('@everyone', '@ everyone')

                # First, buffer messages by channel
                joined_messages[message.channel].append(message)

            except queue.Empty:  # Then process them together when we run out of things in the queue
                for channel, messages in joined_messages.items():
                    next_message = []
                    length = 0
                    current_sender = None
                    # We group messages here to avoid being throttled as often. In short, we want to send a message when:
                    # 1) The virtual sender (for webhook purposes) changes
                    # 2) We reach the message limit for one batch (2000 chars)
                    # 3) We run out of messages at the end
                    while messages:
                        message = messages.popleft()
                        next_message.append(message.text)
                        length += len(message.text)

                        if message.sender != current_sender or length >= self.MAX_MESSAGE_SIZE:
                            current_sender = message.sender
                            _send(current_sender, channel, message.pylink_target, next_message)
                            next_message.clear()
                            length = 0

                    # The last batch
                    if next_message:
                        _send(current_sender, channel, message.pylink_target, next_message)

                joined_messages.clear()
            except Exception:
                log.exception("Exception in message queueing thread:")

    def _create_child(self, server_id, guild_name):
        """
        Creates a virtual network object for a server with the given name.
        """
        # This is a bit different because we let the child server find its own name
        # and report back to us.
        child = DiscordServer(None, self, server_id, guild_name)
        world.networkobjects[child.name] = self._children[server_id] = child
        return child

    def _remove_child(self, server_id):
        """
        Removes a virtual network object with the given name.
        """
        pylink_netobj = self._children[server_id]
        pylink_netobj.call_hooks([None, 'PYLINK_DISCONNECT', {}])
        del self._children[server_id]
        del world.networkobjects[pylink_netobj.name]

    def connect(self):
        self._aborted.clear()
        self._message_thread = threading.Thread(name="Messaging thread for %s" % self.name,
                                                target=self._message_builder, daemon=True)
        self._message_thread.start()
        self.client.run()

    def disconnect(self):
        """Disconnects from Discord and shuts down this network object."""
        self._aborted.set()

        self._pre_disconnect()

        children = self._children.copy()
        for child in children:
            self._remove_child(child)

        self.client.gw.shutting_down = True
        self.client.gw.ws.close()

        self._post_disconnect()
Ejemplo n.º 2
0
class PyLinkDiscordProtocol(PyLinkNetworkCoreWithUtils):
    S2S_BUFSIZE = 0

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if 'token' not in self.serverdata:
            raise ProtocolError("No API token defined under server settings")
        self.client_config = ClientConfig({'token': self.serverdata['token']})
        self.client = Client(self.client_config)
        self.bot_config = BotConfig()
        self.bot = Bot(self.client, self.bot_config)
        self.bot_plugin = DiscordBotPlugin(self, self.bot, self.bot_config)
        self.bot.add_plugin(self.bot_plugin)
        #setup_logging(level='DEBUG')
        self._children = {}
        self.message_queue = queue.Queue()

    @staticmethod
    def is_nick(s, nicklen=None):
        return True

    def is_channel(self, s):
        """Returns whether the target is a channel."""
        try:
            chan = int(s)
        except (TypeError, ValueError):
            return False
        return chan in self.bot_plugin.state.channels

    def is_server_name(self, s):
        """Returns whether the string given is a valid IRC server name."""
        return s in self.bot_plugin.state.guilds

    def get_friendly_name(self, entityid, caller=None):
        """
        Returns the friendly name of a SID (the guild name), UID (the nick), or channel (the name).
        """
        # internal PUID, handle appropriately
        if isinstance(entityid, str) and '@' in entityid:
            if entityid in self.users:
                return self.users[entityid].nick
            elif caller and entityid in caller.users:
                return caller.users[entityid].nick
            else:
                # Probably a server
                return entityid.split('@', 1)[0]

        if self.is_channel(entityid):
            return str(self.bot_plugin.state.channels[entityid])
        elif entityid in self.bot_plugin.state.users:
            return self.bot_plugin.state.users[entityid].username
        elif self.is_server_name(entityid):
            return self.bot_plugin.state.guilds[entityid].name
        else:
            raise KeyError("Unknown entity ID %s" % str(entityid))

    @staticmethod
    def wrap_message(source, target, text):
        """
        STUB: returns the message text wrapped onto multiple lines.
        """
        return [text]

    def _message_builder(self):
        current_channel_senders = {}
        joined_messages = collections.defaultdict(dict)
        while not self._aborted.is_set():
            try:
                message = self.message_queue.get(timeout=BATCH_DELAY)
                message_text = message.pop('text', '')
                channel = message.pop('target')
                current_sender = current_channel_senders.get(channel, None)

                # We'll enable this when we work on webhook support again...
                #if current_sender != message['sender']:
                #    self.flush(channel, joined_messages[channel])
                #    joined_messages[channel] = message

                current_channel_senders[channel] = message['sender']

                joined_message = joined_messages[channel].get('text', '')
                joined_messages[channel][
                    'text'] = joined_message + "\n{}".format(message_text)
            except queue.Empty:
                for channel, message_info in joined_messages.items():
                    self.flush(channel, message_info)
                joined_messages.clear()
                current_channel_senders.clear()

    def flush(self, channel, message_info):
        message_text = message_info.pop('text', '').strip()
        if message_text:
            if message_info.get('username'):
                message_info['webhook'].execute(
                    content=message_text,
                    username=message_info['username'],
                    avatar_url=message_info.get('avatar'),
                )
            else:
                channel.send_message(message_text)

    def _create_child(self, server_id, guild_name):
        """
        Creates a virtual network object for a server with the given name.
        """
        # Try to find a predefined server name; if that fails, use the server id.
        # We don't use the guild name here because those can be changed at any time,
        # confusing plugins that store data by PyLink network names.
        fallback_name = 'd%d' % server_id
        name = self.serverdata.get('server_names',
                                   {}).get(server_id, fallback_name)

        if name in world.networkobjects:
            raise ValueError("Attempting to reintroduce network with name %r" %
                             name)
        child = DiscordServer(name, self, server_id, guild_name)
        world.networkobjects[name] = self._children[server_id] = child
        return child

    def _remove_child(self, server_id):
        """
        Removes a virtual network object with the given name.
        """
        pylink_netobj = self._children[server_id]
        pylink_netobj.call_hooks([None, 'PYLINK_DISCONNECT', {}])
        del self._children[server_id]
        del world.networkobjects[pylink_netobj.name]

    def connect(self):
        self._aborted.clear()
        self.client.run()

    def disconnect(self):
        """Disconnects from Discord and shuts down this network object."""
        self._aborted.set()

        self._pre_disconnect()

        children = self._children.copy()
        for child in children:
            self._remove_child(child)

        self.client.gw.shutting_down = True
        self.client.gw.ws.close()

        self._post_disconnect()
Ejemplo n.º 3
0
class PyLinkDiscordProtocol(PyLinkNetworkCoreWithUtils):

    def __init__(self, *args, **kwargs):
        from gevent import monkey
        monkey.patch_all()
        super().__init__(*args, **kwargs)
        self._hooks_queue = queue.Queue()

        if 'token' not in self.serverdata:
            raise ProtocolError("No API token defined under server settings")
        self.client_config = ClientConfig({'token': self.serverdata['token']})
        self.client = Client(self.client_config)
        self.bot_config = BotConfig()
        self.bot = Bot(self.client, self.bot_config)
        self.bot_plugin = DiscordBotPlugin(self, self.bot, self.bot_config)
        self.bot.add_plugin(self.bot_plugin)
        setup_logging(level='DEBUG')
        self._children = {}
        self.message_queue = queue.Queue()

    def _message_builder(self):
        current_channel_senders = {}
        joined_messages = defaultdict(dict)
        while not self._aborted.is_set():
            try:
                message = self.message_queue.get(timeout=0.1)
                message_text = message.pop('text', '')
                channel = message.pop('target')
                current_sender = current_channel_senders.get(channel, None)

                if current_sender != message['sender']:
                    self.flush(channel, joined_messages[channel])
                    joined_messages[channel] = message

                current_channel_senders[channel] = message['sender']

                joined_message = joined_messages[channel].get('text', '')
                joined_messages[channel]['text'] = joined_message + "\n{}".format(message_text)
            except queue.Empty:
                for channel, message_info in joined_messages.items():
                    self.flush(channel, message_info)
                joined_messages = defaultdict(dict)
                current_channel_senders = {}

    def flush(self, channel, message_info):
        message_text = message_info.pop('text', '').strip()
        if message_text:
            if message_info.get('username'):
                message_info['webhook'].execute(
                    content=message_text,
                    username=message_info['username'],
                    avatar_url=message_info.get('avatar'),
                    )
            else:
                channel.send_message(message_text)


    def _process_hooks(self):
        """Loop to process incoming hook data."""
        while not self._aborted.is_set():
            data = self._hooks_queue.get()
            if data is None:
                log.debug('(%s) Stopping queue thread due to getting None as item', self.name)
                break
            elif self not in world.networkobjects.values():
                log.debug('(%s) Stopping stale queue thread; no longer matches world.networkobjects', self.name)
                break

            subserver, data = data
            if subserver not in world.networkobjects:
                log.error('(%s) Not queuing hook for subserver %r no longer in networks list.',
                          self.name, subserver)
            elif subserver in self._children:
                self._children[subserver].call_hooks(data)

    def _add_hook(self, subserver, data):
        """
        Pushes a hook payload for the given subserver.
        """
        if subserver not in self._children:
            raise ValueError("Unknown subserver %s" % subserver)
        self._hooks_queue.put_nowait((
            subserver,
            data
        ))

    def _create_child(self, name, server_id):
        """
        Creates a virtual network object for a server with the given name.
        """
        if name in world.networkobjects:
            raise ValueError("Attempting to reintroduce network with name %r" % name)
        child = DiscordServer(name, self, server_id)
        world.networkobjects[name] = self._children[name] = child
        return child

    def _remove_child(self, name):
        """
        Removes a virtual network object with the given name.
        """
        self._add_hook(name, [None, 'PYLINK_DISCONNECT', {}])
        del self._children[name]
        del world.networkobjects[name]

    def connect(self):
        self._aborted.clear()

        self._queue_thread = threading.Thread(name="Queue thread for %s" % self.name,
                                             target=self._process_hooks, daemon=True)
        self._queue_thread.start()

        self._message_thread = threading.Thread(name="Message thread for %s" % self.name,
                                                target=self._message_builder, daemon=True)
        self._message_thread.start()

        self.client.run()

    def websocket_close(self, *_, **__):
        return self.disconnect()

    def disconnect(self):
        self._aborted.set()

        self._pre_disconnect()

        log.debug('(%s) Killing hooks handler', self.name)
        try:
            # XXX: queue.Queue.queue isn't actually documented, so this is probably not reliable in the long run.
            with self._hooks_queue.mutex:
                self._hooks_queue.queue[0] = None
        except IndexError:
            self._hooks_queue.put(None)

        children = self._children.copy()
        for child in children:
            self._remove_child(child)

        if world.shutting_down.is_set():
            self.bot.client.gw.shutting_down = True
        log.debug('(%s) Sending Discord logout', self.name)
        self.bot.client.gw.session_id = None
        self.bot.client.gw.ws.close()

        self._post_disconnect()