Repository: sopel-irc/sopel

Issue:
adminchannel: empty `.tmask` handled incorrectly
### Description

Running `.tmask` without any argument technically sets an empty topic mask, but in a broken way.

### Reproduction steps

0. Enable `adminchannel` plugin if not enabled
1. As chanop, do just `.tmask` (no argument)
2. Do `.showmask`
3. Sopel responds with an exception:
   `Unexpected TypeError (can only concatenate str (not "NoneType") to str) from dgw. Message was: .showmask`

### Expected behavior

`adminchannel` should properly _clear_ the topic mask in this case, such that doing `.showmask` yields the same output as if `.tmask` had never been set at all.

Additionally, `.showmask` should handle `None` as if the topic mask is unset. Defense in depth!

### Relevant logs

_No response_

### Notes

I've a half-assed patch for this in `adminchannel-tmask-none` branch, but it's untested and probably needs some more work before becoming a pull request.

### Sopel version

973a489355540d68b95db01a49e983ac7a740bcc

### Installation method

`pip install`

### Python version

3.10.7

### Operating system

_No response_

### IRCd

_No response_

### Relevant plugins

adminchannel


Discussion:

Resolving patch:
diff --git a/sopel/builtins/adminchannel.py b/sopel/builtins/adminchannel.py
--- a/sopel/builtins/adminchannel.py
+++ b/sopel/builtins/adminchannel.py
@@ -331,9 +331,8 @@ def topic(bot, trigger):
         return
     channel = trigger.sender.lower()
 
-    mask = None
-    mask = bot.db.get_channel_value(channel, 'topic_mask')
-    mask = mask or default_mask(trigger)
+    mask = bot.db.get_channel_value(
+        channel, 'topic_mask', default_mask(trigger))
     mask = mask.replace('%s', '{}')
     narg = len(re.findall('{}', mask))
 
@@ -362,28 +361,48 @@ def topic(bot, trigger):
     bot.write(('TOPIC', channel + ' :' + topic))
 
 
-@plugin.require_chanmsg
 @plugin.require_privilege(plugin.OP, ERROR_MESSAGE_NO_PRIV)
-@plugin.command('tmask')
-def set_mask(bot, trigger):
-    """Set the topic mask to use for the current channel
+@plugin.commands('tmask set', 'tmask get', 'tmask clear', 'tmask')
+@plugin.example('.tmask clear', user_help=True)
+@plugin.example('.tmask get', user_help=True)
+@plugin.example('.tmask set My {} topic mask!', user_help=True)
+def topic_mask_management(bot, trigger):
+    """Set, get, or clear the current channel's topic mask.
+
+    Recognized subcommands are 'set', 'get', and 'clear'. A plain 'tmask'
+    command with no arguments is equivalent to 'tmask get'.
+
+    This mask is used by the 'topic' command. `{}` allows interpolating a chunk
+    of text within the topic mask template.
+    """
+    command, _, subcommand = trigger.group(1).partition(' ')
 
-    Within the topic mask, {} is used to allow substituting in chunks of text.
+    if not subcommand or subcommand == 'get':
+        mask = bot.db.get_channel_value(
+            trigger.sender, 'topic_mask', default_mask(trigger))
+        bot.reply('Current topic mask: {}'.format(mask))
+        return
 
-    This mask is used when running the 'topic' command.
-    """
-    bot.db.set_channel_value(trigger.sender, 'topic_mask', trigger.group(2))
-    message = (
-        'Topic mask set. '
-        'Use `{prefix}topic <args>` to set topic '
-        'and `{prefix}showmask` to see current mask.'
-    ).format(prefix=bot.settings.core.help_prefix)
-    bot.reply(message)
+    if subcommand == 'set':
+        if not trigger.group(2):
+            message = (
+                'I need a non-empty topic mask to set. '
+                'To delete the saved topic mask, use `{prefix}tmask clear`.'
+            ).format(prefix=bot.settings.core.help_prefix)
+            bot.reply(message)
+            return
 
+        bot.db.set_channel_value(trigger.sender, 'topic_mask', trigger.group(2))
+        message = (
+            'Topic mask set. '
+            'Use `{prefix}topic <args>` to set topic, '
+            '`{prefix}tmask get` to see the current mask, '
+            'and `{prefix}tmask clear` to delete the saved topic mask.'
+        ).format(prefix=bot.settings.core.help_prefix)
+        bot.reply(message)
+        return
 
-@plugin.require_chanmsg
-@plugin.require_privilege(plugin.OP, ERROR_MESSAGE_NO_PRIV)
-@plugin.command('showmask')
-def show_mask(bot, trigger):
-    """Show the topic mask for the current channel."""
-    bot.say(bot.db.get_channel_value(trigger.sender, 'topic_mask', default_mask(trigger)))
+    if subcommand == 'clear':
+        bot.db.delete_channel_value(trigger.sender, 'topic_mask')
+        bot.reply('Cleared topic mask.')
+        return


Test patch: