Repository: sopel-irc/sopel

Issue:
Sopel lacks the ability to send custom commands on connect
Think ZNC's `perform` module, but without the ability to add/remove/rearrange lines from an IRC query.

I noted this down a while ago, but forgot about it until someone asked how to do this in #sopel tonight.

We want to keep this in the existing config file, but slipping this into the existing config value types could prove to be _interesting_. A custom login command might contain, say, a comma, because special characters in passwords are encouraged by modern recommendations. `ListAttribute` (the most logical option) doesn't escape commas at all, so it would split the values incorrectly upon reading them back.

(A separate PR implementing such escaping would be welcomed, if anyone's interested in working on it.)


Discussion:

Resolving patch:
diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py
--- a/sopel/config/core_section.py
+++ b/sopel/config/core_section.py
@@ -49,6 +49,10 @@ def configure(config):
         'channels',
         'Enter the channels to connect to at startup, separated by commas.'
     )
+    config.core.configure_setting(
+        'commands_on_connect',
+        'Enter commands to perform on successful connection to server (one per \'?\' prompt).'
+    )
 
 
 class CoreSection(StaticSection):
@@ -347,6 +351,23 @@ def homedir(self):
     capabilities.
     """
 
+    commands_on_connect = ListAttribute('commands_on_connect')
+    """A list of commands to perform upon successful connection to IRC server.
+
+    Each line is a message that will be sent to the server once connected.
+    Example::
+
+        PRIVMSG Q@CServe.quakenet.org :AUTH my_username MyPassword,@#$%!
+        PRIVMSG MyOwner :I'm here!
+
+    ``$nickname`` can be used in a command as a placeholder, and it will be
+    replaced with the bot's :attr:`~CoreSection.nick`. For example when the
+    nick is ``Sopel``, then this ``MODE $nickname +Xxw`` will become
+    ``MODE Sopel +Xxw``.
+
+    .. versionadded:: 7.0
+    """
+
     pid_dir = FilenameAttribute('pid_dir', directory=True, default='.')
     """The directory in which to put the file Sopel uses to track its process ID.
 
diff --git a/sopel/coretasks.py b/sopel/coretasks.py
--- a/sopel/coretasks.py
+++ b/sopel/coretasks.py
@@ -62,6 +62,27 @@ def auth_after_register(bot):
                 auth_target or 'UserServ')
 
 
+def _execute_perform(bot):
+    """Execute commands specified to perform on IRC server connect."""
+    if not bot.connection_registered:
+        # How did you even get this command, bot?
+        raise Exception('Bot must be connected to server to perform commands.')
+
+    LOGGER.debug('{} commands to execute:'.format(len(bot.config.core.commands_on_connect)))
+    for i, command in enumerate(bot.config.core.commands_on_connect):
+        command = command.replace('$nickname', bot.config.core.nick)
+        LOGGER.debug(command)
+        bot.write((command,))
+
+
+@sopel.module.require_privmsg("This command only works as a private message.")
+@sopel.module.require_admin("This command requires admin privileges.")
+@sopel.module.commands('execute')
+def execute_perform(bot, trigger):
+    """Execute commands specified to perform on IRC server connect."""
+    _execute_perform(bot)
+
+
 @sopel.module.event(events.RPL_WELCOME, events.RPL_LUSERCLIENT)
 @sopel.module.thread(False)
 @sopel.module.unblockable
@@ -111,6 +132,8 @@ def startup(bot, trigger):
         ).format(bot.config.core.help_prefix)
         bot.say(msg, bot.config.core.owner)
 
+    _execute_perform(bot)
+
 
 @sopel.module.require_privmsg()
 @sopel.module.require_owner()


Test patch:
diff --git a/test/test_coretasks.py b/test/test_coretasks.py
--- a/test/test_coretasks.py
+++ b/test/test_coretasks.py
@@ -4,6 +4,7 @@
 
 import pytest
 
+from sopel import coretasks
 from sopel.module import VOICE, HALFOP, OP, ADMIN, OWNER
 from sopel.tools import Identifier
 from sopel.tests import rawlist
@@ -111,3 +112,39 @@ def test_mode_colon(mockbot, ircfactory):
 
     assert mockbot.channels["#test"].privileges[Identifier("Uvoice")] == VOICE
     assert mockbot.channels["#test"].privileges[Identifier("Uadmin")] == ADMIN
+
+
+def test_execute_perform_raise_not_connected(mockbot):
+    """Ensure bot will not execute ``commands_on_connect`` unless connected."""
+    with pytest.raises(Exception):
+        coretasks._execute_perform(mockbot)
+
+
+def test_execute_perform_send_commands(mockbot):
+    """Ensure bot sends ``commands_on_connect`` as specified in config."""
+    commands = [
+        # Example command for identifying to services on Undernet
+        'PRIVMSG X@Channels.undernet.org :LOGIN my_username my_password',
+        # Set modes on connect
+        'MODE some_nick +Xx',
+        # Oper on connect
+        'OPER oper_username oper_password',
+    ]
+
+    mockbot.config.core.commands_on_connect = commands
+    mockbot.connection_registered = True
+
+    coretasks._execute_perform(mockbot)
+    assert mockbot.backend.message_sent == rawlist(*commands)
+
+
+def test_execute_perform_replaces_nickname(mockbot):
+    """Confirm that bot replaces ``$nickname`` placeholder in commands."""
+    command = 'MODE $nickname +Xxw'
+    sent_command = 'MODE {} +Xxw'.format(mockbot.config.core.nick)
+
+    mockbot.config.core.commands_on_connect = [command, ]
+    mockbot.connection_registered = True  # For testing, simulate connected
+
+    coretasks._execute_perform(mockbot)
+    assert mockbot.backend.message_sent == rawlist(sent_command)