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()
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()
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()