We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
tell: argument splitting/stripping is a bit broken
This code line removes most IRC formatting bytes from the beginning of the message:

https://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/tell.py#L145

It's similar to another issue (#1877) we fixed in `choose` for 7.1 (#1965). The second `.lstrip()` with no arguments needs to be replaced with this plugin's own version of [`_format_safe()`](https://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/choose.py#L19-L69) from `choose.py`.

In fact, it wouldn't take much at this point to convince me that Sopel's API should include its own formatting-safe versions of `strip()`, `lstrip()`, and `rstrip()`. Issues like this keep coming up, and stuff like `trigger.plain` won't help…

----

That line also misuses `.lstrip()` with the tellee's nick. That isn't a huge issue, because it's never going to go past the whitespace, but it could cause other quirks like the fact that punctuation gets through even though the code's _intent_ appears to be treating the tellee as a separate argument that isn't part of the message:

```irclog
<~dgw> ;tell Creator|TL, comma test
[…]
<&Kaede> [tell] Creator|TL: Monday, July 12, 2021 22:52:44 (IST) <dgw> tell Creator|TL , comma test
```



--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `sopel/modules/tell.py`
Content:
```
1 """
2 tell.py - Sopel Tell and Ask Plugin
3 Copyright 2008, Sean B. Palmer, inamidst.com
4 Copyright 2019, dgw, technobabbl.es
5 Licensed under the Eiffel Forum License 2.
6 
7 https://sopel.chat
8 """
9 from __future__ import generator_stop
10 
11 from collections import defaultdict
12 import io  # don't use `codecs` for loading the DB; it will split lines on some IRC formatting
13 import logging
14 import os
15 import threading
16 import time
17 
18 from sopel import plugin, tools
19 from sopel.config import types
20 from sopel.tools.time import format_time, get_timezone
21 
22 
23 LOGGER = logging.getLogger(__name__)
24 
25 
26 class TellSection(types.StaticSection):
27     use_private_reminder = types.BooleanAttribute(
28         'use_private_reminder', default=False)
29     """When set to ``true``, Sopel will send reminder as private message."""
30     maximum_public = types.ValidatedAttribute(
31         'maximum_public', parse=int, default=4)
32     """How many Sopel can send in public before using private message."""
33 
34 
35 def configure(config):
36     """
37     | name | example | purpose |
38     | ---- | ------- | ------- |
39     | use_private_reminder | false | Send reminders as private message |
40     | maximum_public | 4 | Send up to this amount of reminders in public |
41     """
42     config.define_section('tell', TellSection)
43     config.tell.configure_setting(
44         'use_private_reminder',
45         'Should Sopel send tell/ask reminders as private message only?')
46     if not config.tell.use_private_reminder:
47         config.tell.configure_setting(
48             'maximum_public',
49             'How many tell/ask reminders Sopel will send as public message '
50             'before sending them as private messages?')
51 
52 
53 def load_reminders(filename):
54     """Load tell/ask reminders from a ``filename``.
55 
56     :param str filename: path to the tell/ask reminders file
57     :return: a dict with the tell/ask reminders
58     :rtype: dict
59     """
60     result = defaultdict(list)
61     with io.open(filename, 'r', encoding='utf-8') as fd:
62         for line in fd:
63             line = line.strip()
64             if line:
65                 try:
66                     tellee, teller, verb, timenow, msg = line.split('\t', 4)
67                 except ValueError:
68                     continue  # TODO: Add warning log about malformed reminder
69                 result[tellee].append((teller, verb, timenow, msg))
70 
71     return result
72 
73 
74 def dump_reminders(filename, data):
75     """Dump tell/ask reminders (``data``) into a ``filename``.
76 
77     :param str filename: path to the tell/ask reminders file
78     :param dict data: tell/ask reminders ``dict``
79     """
80     with io.open(filename, 'w', encoding='utf-8') as fd:
81         for tellee, reminders in data.items():
82             for reminder in reminders:
83                 line = '\t'.join((tellee,) + tuple(reminder))
84                 fd.write(line + '\n')
85     return True
86 
87 
88 def setup(bot):
89     bot.config.define_section('tell', TellSection)
90     fn = bot.config.basename + '.tell.db'
91     bot.tell_filename = os.path.join(bot.config.core.homedir, fn)
92 
93     # Pre-7.0 migration logic. Remove in 8.0 or 9.0.
94     old = bot.nick + '-' + bot.config.core.host + '.tell.db'
95     old = os.path.join(bot.config.core.homedir, old)
96     if os.path.isfile(old):
97         LOGGER.info("Attempting to migrate old 'tell' database {}..."
98                     .format(old))
99         try:
100             os.rename(old, bot.tell_filename)
101         except OSError:
102             LOGGER.error("Migration failed!")
103             LOGGER.error("Old filename: {}".format(old))
104             LOGGER.error("New filename: {}".format(bot.tell_filename))
105             LOGGER.error(
106                 "See https://sopel.chat/usage/installing/upgrading-to-sopel-7/#reminder-db-migration")
107         else:
108             LOGGER.info("Migration finished!")
109     # End migration logic
110 
111     if not os.path.exists(bot.tell_filename):
112         with io.open(bot.tell_filename, 'w', encoding='utf-8') as fd:
113             # if we can't open/write into the file, the tell plugin can't work
114             fd.write('')
115 
116     if 'tell_lock' not in bot.memory:
117         bot.memory['tell_lock'] = threading.Lock()
118 
119     if 'reminders' not in bot.memory:
120         with bot.memory['tell_lock']:
121             bot.memory['reminders'] = load_reminders(bot.tell_filename)
122 
123 
124 def shutdown(bot):
125     for key in ['tell_lock', 'reminders']:
126         try:
127             del bot.memory[key]
128         except KeyError:
129             pass
130 
131 
132 @plugin.command('tell', 'ask')
133 @plugin.nickname_command('tell', 'ask')
134 @plugin.example('$nickname, tell dgw he broke something again.')
135 def f_remind(bot, trigger):
136     """Give someone a message the next time they're seen"""
137     teller = trigger.nick
138     verb = trigger.group(1)
139 
140     if not trigger.group(3):
141         bot.reply("%s whom?" % verb)
142         return
143 
144     tellee = trigger.group(3).rstrip('.,:;')
145     msg = trigger.group(2).lstrip(tellee).lstrip()
146 
147     if not msg:
148         bot.reply("%s %s what?" % (verb, tellee))
149         return
150 
151     tellee = tools.Identifier(tellee)
152 
153     if not os.path.exists(bot.tell_filename):
154         return
155 
156     if len(tellee) > bot.isupport.get('NICKLEN', 30):
157         bot.reply('That nickname is too long.')
158         return
159 
160     if tellee[0] == '@':
161         tellee = tellee[1:]
162 
163     if tellee == bot.nick:
164         bot.reply("I'm here now; you can %s me whatever you want!" % verb)
165         return
166 
167     if tellee not in (tools.Identifier(teller), bot.nick, 'me'):
168         tz = get_timezone(bot.db, bot.config, None, tellee)
169         timenow = format_time(bot.db, bot.config, tz, tellee)
170         with bot.memory['tell_lock']:
171             if tellee not in bot.memory['reminders']:
172                 bot.memory['reminders'][tellee] = [(teller, verb, timenow, msg)]
173             else:
174                 bot.memory['reminders'][tellee].append((teller, verb, timenow, msg))
175             # save the reminders
176             dump_reminders(bot.tell_filename, bot.memory['reminders'])
177 
178         response = "I'll pass that on when %s is around." % tellee
179         bot.reply(response)
180     elif tools.Identifier(teller) == tellee:
181         bot.reply('You can %s yourself that.' % verb)
182     else:
183         bot.reply("Hey, I'm not as stupid as Monty you know!")
184 
185 
186 def get_nick_reminders(reminders, nick):
187     lines = []
188     template = "%s: %s <%s> %s %s %s"
189     today = time.strftime('%d %b', time.gmtime())
190 
191     for (teller, verb, datetime, msg) in reminders:
192         if datetime.startswith(today):
193             datetime = datetime[len(today) + 1:]
194         lines.append(template % (nick, datetime, teller, verb, nick, msg))
195 
196     return lines
197 
198 
199 def nick_match_tellee(nick, tellee):
200     """Tell if a ``nick`` matches a ``tellee``.
201 
202     :param str nick: Nick seen by the bot
203     :param str tellee: Tellee name or pattern
204 
205     The check between ``nick`` and ``tellee`` is case-insensitive::
206 
207         >>> nick_match_tellee('Exirel', 'exirel')
208         True
209         >>> nick_match_tellee('exirel', 'EXIREL')
210         True
211         >>> nick_match_tellee('exirel', 'dgw')
212         False
213 
214     If ``tellee`` ends with a wildcard token (``*`` or ``:``), then ``nick``
215     matches if it starts with ``tellee`` (without the token)::
216 
217         >>> nick_match_tellee('Exirel', 'Exi*')
218         True
219         >>> nick_match_tellee('Exirel', 'exi:')
220         True
221         >>> nick_match_tellee('Exirel', 'Exi')
222         False
223 
224     Note that this is still case-insensitive.
225     """
226     if tellee[-1] in ['*', ':']:  # these are wildcard token
227         return nick.lower().startswith(tellee.lower().rstrip('*:'))
228     return nick.lower() == tellee.lower()
229 
230 
231 @plugin.rule('(.*)')
232 @plugin.priority('low')
233 @plugin.unblockable
234 @plugin.output_prefix('[tell] ')
235 def message(bot, trigger):
236     nick = trigger.nick
237 
238     if not os.path.exists(bot.tell_filename):
239         # plugin can't work without its storage file
240         return
241 
242     # get all matching reminders
243     reminders = []
244     tellees = list(reversed(sorted(
245         tellee
246         for tellee in bot.memory['reminders']
247         if nick_match_tellee(nick, tellee)
248     )))
249 
250     with bot.memory['tell_lock']:
251         # pop reminders for nick
252         reminders = list(
253             reminder
254             for tellee in tellees
255             for reminder in get_nick_reminders(
256                 bot.memory['reminders'].pop(tellee, []), nick)
257         )
258 
259     # check if there are reminders to send
260     if not reminders:
261         return  # nothing to do
262 
263     # then send reminders (as public and/or private messages)
264     if bot.config.tell.use_private_reminder:
265         # send reminders with private messages
266         for line in reminders:
267             bot.say(line, nick)
268     else:
269         # send up to 'maximum_public' reminders to the channel
270         max_public = bot.config.tell.maximum_public
271         for line in reminders[:max_public]:
272             bot.say(line)
273 
274         # send other reminders directly to nick as private message
275         if reminders[max_public:]:
276             bot.reply('Further messages sent privately')
277             for line in reminders[max_public:]:
278                 bot.say(line, nick)
279 
280     # save reminders left in memory
281     with bot.memory['tell_lock']:
282         dump_reminders(bot.tell_filename, bot.memory['reminders'])
283
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/sopel/modules/tell.py b/sopel/modules/tell.py
--- a/sopel/modules/tell.py
+++ b/sopel/modules/tell.py
@@ -14,8 +14,9 @@
 import os
 import threading
 import time
+import unicodedata
 
-from sopel import plugin, tools
+from sopel import formatting, plugin, tools
 from sopel.config import types
 from sopel.tools.time import format_time, get_timezone
 
@@ -129,6 +130,43 @@
             pass
 
 
+def _format_safe_lstrip(text):
+    """``str.lstrip()`` but without eating IRC formatting.
+
+    :param str text: text to clean
+    :rtype: str
+    :raises TypeError: if the passed ``text`` is not a string
+
+    Stolen and tweaked from the ``choose`` plugin's ``_format_safe()``
+    function by the person who wrote it.
+    """
+    if not isinstance(text, str):
+        raise TypeError("A string is required.")
+    elif not text:
+        # unnecessary optimization
+        return ''
+
+    start = 0
+
+    # strip left
+    pos = 0
+    while pos < len(text):
+        is_whitespace = unicodedata.category(text[pos]) == 'Zs'
+        is_non_printing = (
+            text[pos] in formatting.CONTROL_NON_PRINTING and
+            text[pos] not in formatting.CONTROL_FORMATTING
+        )
+        if not is_whitespace and not is_non_printing:
+            start = pos
+            break
+        pos += 1
+    else:
+        # skipped everything; string is all whitespace
+        return ''
+
+    return text[start:]
+
+
 @plugin.command('tell', 'ask')
 @plugin.nickname_command('tell', 'ask')
 @plugin.example('$nickname, tell dgw he broke something again.')
@@ -142,7 +180,7 @@
         return
 
     tellee = trigger.group(3).rstrip('.,:;')
-    msg = trigger.group(2).lstrip(tellee).lstrip()
+    msg = _format_safe_lstrip(trigger.group(2).split(' ', 1)[1])
 
     if not msg:
         bot.reply("%s %s what?" % (verb, tellee))

{"golden_diff": "diff --git a/sopel/modules/tell.py b/sopel/modules/tell.py\n--- a/sopel/modules/tell.py\n+++ b/sopel/modules/tell.py\n@@ -14,8 +14,9 @@\n import os\n import threading\n import time\n+import unicodedata\n \n-from sopel import plugin, tools\n+from sopel import formatting, plugin, tools\n from sopel.config import types\n from sopel.tools.time import format_time, get_timezone\n \n@@ -129,6 +130,43 @@\n             pass\n \n \n+def _format_safe_lstrip(text):\n+    \"\"\"``str.lstrip()`` but without eating IRC formatting.\n+\n+    :param str text: text to clean\n+    :rtype: str\n+    :raises TypeError: if the passed ``text`` is not a string\n+\n+    Stolen and tweaked from the ``choose`` plugin's ``_format_safe()``\n+    function by the person who wrote it.\n+    \"\"\"\n+    if not isinstance(text, str):\n+        raise TypeError(\"A string is required.\")\n+    elif not text:\n+        # unnecessary optimization\n+        return ''\n+\n+    start = 0\n+\n+    # strip left\n+    pos = 0\n+    while pos < len(text):\n+        is_whitespace = unicodedata.category(text[pos]) == 'Zs'\n+        is_non_printing = (\n+            text[pos] in formatting.CONTROL_NON_PRINTING and\n+            text[pos] not in formatting.CONTROL_FORMATTING\n+        )\n+        if not is_whitespace and not is_non_printing:\n+            start = pos\n+            break\n+        pos += 1\n+    else:\n+        # skipped everything; string is all whitespace\n+        return ''\n+\n+    return text[start:]\n+\n+\n @plugin.command('tell', 'ask')\n @plugin.nickname_command('tell', 'ask')\n @plugin.example('$nickname, tell dgw he broke something again.')\n@@ -142,7 +180,7 @@\n         return\n \n     tellee = trigger.group(3).rstrip('.,:;')\n-    msg = trigger.group(2).lstrip(tellee).lstrip()\n+    msg = _format_safe_lstrip(trigger.group(2).split(' ', 1)[1])\n \n     if not msg:\n         bot.reply(\"%s %s what?\" % (verb, tellee))\n", "issue": "tell: argument splitting/stripping is a bit broken\nThis code line removes most IRC formatting bytes from the beginning of the message:\r\n\r\nhttps://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/tell.py#L145\r\n\r\nIt's similar to another issue (#1877) we fixed in `choose` for 7.1 (#1965). The second `.lstrip()` with no arguments needs to be replaced with this plugin's own version of [`_format_safe()`](https://github.com/sopel-irc/sopel/blob/f29c10dde2ff166dee15bc49f0cd43c93b93ba3e/sopel/modules/choose.py#L19-L69) from `choose.py`.\r\n\r\nIn fact, it wouldn't take much at this point to convince me that Sopel's API should include its own formatting-safe versions of `strip()`, `lstrip()`, and `rstrip()`. Issues like this keep coming up, and stuff like `trigger.plain` won't help\u2026\r\n\r\n----\r\n\r\nThat line also misuses `.lstrip()` with the tellee's nick. That isn't a huge issue, because it's never going to go past the whitespace, but it could cause other quirks like the fact that punctuation gets through even though the code's _intent_ appears to be treating the tellee as a separate argument that isn't part of the message:\r\n\r\n```irclog\r\n<~dgw> ;tell Creator|TL, comma test\r\n[\u2026]\r\n<&Kaede> [tell] Creator|TL: Monday, July 12, 2021 22:52:44 (IST) <dgw> tell Creator|TL , comma test\r\n```\r\n\r\n\n", "before_files": [{"content": "\"\"\"\ntell.py - Sopel Tell and Ask Plugin\nCopyright 2008, Sean B. Palmer, inamidst.com\nCopyright 2019, dgw, technobabbl.es\nLicensed under the Eiffel Forum License 2.\n\nhttps://sopel.chat\n\"\"\"\nfrom __future__ import generator_stop\n\nfrom collections import defaultdict\nimport io  # don't use `codecs` for loading the DB; it will split lines on some IRC formatting\nimport logging\nimport os\nimport threading\nimport time\n\nfrom sopel import plugin, tools\nfrom sopel.config import types\nfrom sopel.tools.time import format_time, get_timezone\n\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass TellSection(types.StaticSection):\n    use_private_reminder = types.BooleanAttribute(\n        'use_private_reminder', default=False)\n    \"\"\"When set to ``true``, Sopel will send reminder as private message.\"\"\"\n    maximum_public = types.ValidatedAttribute(\n        'maximum_public', parse=int, default=4)\n    \"\"\"How many Sopel can send in public before using private message.\"\"\"\n\n\ndef configure(config):\n    \"\"\"\n    | name | example | purpose |\n    | ---- | ------- | ------- |\n    | use_private_reminder | false | Send reminders as private message |\n    | maximum_public | 4 | Send up to this amount of reminders in public |\n    \"\"\"\n    config.define_section('tell', TellSection)\n    config.tell.configure_setting(\n        'use_private_reminder',\n        'Should Sopel send tell/ask reminders as private message only?')\n    if not config.tell.use_private_reminder:\n        config.tell.configure_setting(\n            'maximum_public',\n            'How many tell/ask reminders Sopel will send as public message '\n            'before sending them as private messages?')\n\n\ndef load_reminders(filename):\n    \"\"\"Load tell/ask reminders from a ``filename``.\n\n    :param str filename: path to the tell/ask reminders file\n    :return: a dict with the tell/ask reminders\n    :rtype: dict\n    \"\"\"\n    result = defaultdict(list)\n    with io.open(filename, 'r', encoding='utf-8') as fd:\n        for line in fd:\n            line = line.strip()\n            if line:\n                try:\n                    tellee, teller, verb, timenow, msg = line.split('\\t', 4)\n                except ValueError:\n                    continue  # TODO: Add warning log about malformed reminder\n                result[tellee].append((teller, verb, timenow, msg))\n\n    return result\n\n\ndef dump_reminders(filename, data):\n    \"\"\"Dump tell/ask reminders (``data``) into a ``filename``.\n\n    :param str filename: path to the tell/ask reminders file\n    :param dict data: tell/ask reminders ``dict``\n    \"\"\"\n    with io.open(filename, 'w', encoding='utf-8') as fd:\n        for tellee, reminders in data.items():\n            for reminder in reminders:\n                line = '\\t'.join((tellee,) + tuple(reminder))\n                fd.write(line + '\\n')\n    return True\n\n\ndef setup(bot):\n    bot.config.define_section('tell', TellSection)\n    fn = bot.config.basename + '.tell.db'\n    bot.tell_filename = os.path.join(bot.config.core.homedir, fn)\n\n    # Pre-7.0 migration logic. Remove in 8.0 or 9.0.\n    old = bot.nick + '-' + bot.config.core.host + '.tell.db'\n    old = os.path.join(bot.config.core.homedir, old)\n    if os.path.isfile(old):\n        LOGGER.info(\"Attempting to migrate old 'tell' database {}...\"\n                    .format(old))\n        try:\n            os.rename(old, bot.tell_filename)\n        except OSError:\n            LOGGER.error(\"Migration failed!\")\n            LOGGER.error(\"Old filename: {}\".format(old))\n            LOGGER.error(\"New filename: {}\".format(bot.tell_filename))\n            LOGGER.error(\n                \"See https://sopel.chat/usage/installing/upgrading-to-sopel-7/#reminder-db-migration\")\n        else:\n            LOGGER.info(\"Migration finished!\")\n    # End migration logic\n\n    if not os.path.exists(bot.tell_filename):\n        with io.open(bot.tell_filename, 'w', encoding='utf-8') as fd:\n            # if we can't open/write into the file, the tell plugin can't work\n            fd.write('')\n\n    if 'tell_lock' not in bot.memory:\n        bot.memory['tell_lock'] = threading.Lock()\n\n    if 'reminders' not in bot.memory:\n        with bot.memory['tell_lock']:\n            bot.memory['reminders'] = load_reminders(bot.tell_filename)\n\n\ndef shutdown(bot):\n    for key in ['tell_lock', 'reminders']:\n        try:\n            del bot.memory[key]\n        except KeyError:\n            pass\n\n\n@plugin.command('tell', 'ask')\n@plugin.nickname_command('tell', 'ask')\n@plugin.example('$nickname, tell dgw he broke something again.')\ndef f_remind(bot, trigger):\n    \"\"\"Give someone a message the next time they're seen\"\"\"\n    teller = trigger.nick\n    verb = trigger.group(1)\n\n    if not trigger.group(3):\n        bot.reply(\"%s whom?\" % verb)\n        return\n\n    tellee = trigger.group(3).rstrip('.,:;')\n    msg = trigger.group(2).lstrip(tellee).lstrip()\n\n    if not msg:\n        bot.reply(\"%s %s what?\" % (verb, tellee))\n        return\n\n    tellee = tools.Identifier(tellee)\n\n    if not os.path.exists(bot.tell_filename):\n        return\n\n    if len(tellee) > bot.isupport.get('NICKLEN', 30):\n        bot.reply('That nickname is too long.')\n        return\n\n    if tellee[0] == '@':\n        tellee = tellee[1:]\n\n    if tellee == bot.nick:\n        bot.reply(\"I'm here now; you can %s me whatever you want!\" % verb)\n        return\n\n    if tellee not in (tools.Identifier(teller), bot.nick, 'me'):\n        tz = get_timezone(bot.db, bot.config, None, tellee)\n        timenow = format_time(bot.db, bot.config, tz, tellee)\n        with bot.memory['tell_lock']:\n            if tellee not in bot.memory['reminders']:\n                bot.memory['reminders'][tellee] = [(teller, verb, timenow, msg)]\n            else:\n                bot.memory['reminders'][tellee].append((teller, verb, timenow, msg))\n            # save the reminders\n            dump_reminders(bot.tell_filename, bot.memory['reminders'])\n\n        response = \"I'll pass that on when %s is around.\" % tellee\n        bot.reply(response)\n    elif tools.Identifier(teller) == tellee:\n        bot.reply('You can %s yourself that.' % verb)\n    else:\n        bot.reply(\"Hey, I'm not as stupid as Monty you know!\")\n\n\ndef get_nick_reminders(reminders, nick):\n    lines = []\n    template = \"%s: %s <%s> %s %s %s\"\n    today = time.strftime('%d %b', time.gmtime())\n\n    for (teller, verb, datetime, msg) in reminders:\n        if datetime.startswith(today):\n            datetime = datetime[len(today) + 1:]\n        lines.append(template % (nick, datetime, teller, verb, nick, msg))\n\n    return lines\n\n\ndef nick_match_tellee(nick, tellee):\n    \"\"\"Tell if a ``nick`` matches a ``tellee``.\n\n    :param str nick: Nick seen by the bot\n    :param str tellee: Tellee name or pattern\n\n    The check between ``nick`` and ``tellee`` is case-insensitive::\n\n        >>> nick_match_tellee('Exirel', 'exirel')\n        True\n        >>> nick_match_tellee('exirel', 'EXIREL')\n        True\n        >>> nick_match_tellee('exirel', 'dgw')\n        False\n\n    If ``tellee`` ends with a wildcard token (``*`` or ``:``), then ``nick``\n    matches if it starts with ``tellee`` (without the token)::\n\n        >>> nick_match_tellee('Exirel', 'Exi*')\n        True\n        >>> nick_match_tellee('Exirel', 'exi:')\n        True\n        >>> nick_match_tellee('Exirel', 'Exi')\n        False\n\n    Note that this is still case-insensitive.\n    \"\"\"\n    if tellee[-1] in ['*', ':']:  # these are wildcard token\n        return nick.lower().startswith(tellee.lower().rstrip('*:'))\n    return nick.lower() == tellee.lower()\n\n\n@plugin.rule('(.*)')\n@plugin.priority('low')\n@plugin.unblockable\n@plugin.output_prefix('[tell] ')\ndef message(bot, trigger):\n    nick = trigger.nick\n\n    if not os.path.exists(bot.tell_filename):\n        # plugin can't work without its storage file\n        return\n\n    # get all matching reminders\n    reminders = []\n    tellees = list(reversed(sorted(\n        tellee\n        for tellee in bot.memory['reminders']\n        if nick_match_tellee(nick, tellee)\n    )))\n\n    with bot.memory['tell_lock']:\n        # pop reminders for nick\n        reminders = list(\n            reminder\n            for tellee in tellees\n            for reminder in get_nick_reminders(\n                bot.memory['reminders'].pop(tellee, []), nick)\n        )\n\n    # check if there are reminders to send\n    if not reminders:\n        return  # nothing to do\n\n    # then send reminders (as public and/or private messages)\n    if bot.config.tell.use_private_reminder:\n        # send reminders with private messages\n        for line in reminders:\n            bot.say(line, nick)\n    else:\n        # send up to 'maximum_public' reminders to the channel\n        max_public = bot.config.tell.maximum_public\n        for line in reminders[:max_public]:\n            bot.say(line)\n\n        # send other reminders directly to nick as private message\n        if reminders[max_public:]:\n            bot.reply('Further messages sent privately')\n            for line in reminders[max_public:]:\n                bot.say(line, nick)\n\n    # save reminders left in memory\n    with bot.memory['tell_lock']:\n        dump_reminders(bot.tell_filename, bot.memory['reminders'])\n", "path": "sopel/modules/tell.py"}], "after_files": [{"content": "\"\"\"\ntell.py - Sopel Tell and Ask Plugin\nCopyright 2008, Sean B. Palmer, inamidst.com\nCopyright 2019, dgw, technobabbl.es\nLicensed under the Eiffel Forum License 2.\n\nhttps://sopel.chat\n\"\"\"\nfrom __future__ import generator_stop\n\nfrom collections import defaultdict\nimport io  # don't use `codecs` for loading the DB; it will split lines on some IRC formatting\nimport logging\nimport os\nimport threading\nimport time\nimport unicodedata\n\nfrom sopel import formatting, plugin, tools\nfrom sopel.config import types\nfrom sopel.tools.time import format_time, get_timezone\n\n\nLOGGER = logging.getLogger(__name__)\n\n\nclass TellSection(types.StaticSection):\n    use_private_reminder = types.BooleanAttribute(\n        'use_private_reminder', default=False)\n    \"\"\"When set to ``true``, Sopel will send reminder as private message.\"\"\"\n    maximum_public = types.ValidatedAttribute(\n        'maximum_public', parse=int, default=4)\n    \"\"\"How many Sopel can send in public before using private message.\"\"\"\n\n\ndef configure(config):\n    \"\"\"\n    | name | example | purpose |\n    | ---- | ------- | ------- |\n    | use_private_reminder | false | Send reminders as private message |\n    | maximum_public | 4 | Send up to this amount of reminders in public |\n    \"\"\"\n    config.define_section('tell', TellSection)\n    config.tell.configure_setting(\n        'use_private_reminder',\n        'Should Sopel send tell/ask reminders as private message only?')\n    if not config.tell.use_private_reminder:\n        config.tell.configure_setting(\n            'maximum_public',\n            'How many tell/ask reminders Sopel will send as public message '\n            'before sending them as private messages?')\n\n\ndef load_reminders(filename):\n    \"\"\"Load tell/ask reminders from a ``filename``.\n\n    :param str filename: path to the tell/ask reminders file\n    :return: a dict with the tell/ask reminders\n    :rtype: dict\n    \"\"\"\n    result = defaultdict(list)\n    with io.open(filename, 'r', encoding='utf-8') as fd:\n        for line in fd:\n            line = line.strip()\n            if line:\n                try:\n                    tellee, teller, verb, timenow, msg = line.split('\\t', 4)\n                except ValueError:\n                    continue  # TODO: Add warning log about malformed reminder\n                result[tellee].append((teller, verb, timenow, msg))\n\n    return result\n\n\ndef dump_reminders(filename, data):\n    \"\"\"Dump tell/ask reminders (``data``) into a ``filename``.\n\n    :param str filename: path to the tell/ask reminders file\n    :param dict data: tell/ask reminders ``dict``\n    \"\"\"\n    with io.open(filename, 'w', encoding='utf-8') as fd:\n        for tellee, reminders in data.items():\n            for reminder in reminders:\n                line = '\\t'.join((tellee,) + tuple(reminder))\n                fd.write(line + '\\n')\n    return True\n\n\ndef setup(bot):\n    bot.config.define_section('tell', TellSection)\n    fn = bot.config.basename + '.tell.db'\n    bot.tell_filename = os.path.join(bot.config.core.homedir, fn)\n\n    # Pre-7.0 migration logic. Remove in 8.0 or 9.0.\n    old = bot.nick + '-' + bot.config.core.host + '.tell.db'\n    old = os.path.join(bot.config.core.homedir, old)\n    if os.path.isfile(old):\n        LOGGER.info(\"Attempting to migrate old 'tell' database {}...\"\n                    .format(old))\n        try:\n            os.rename(old, bot.tell_filename)\n        except OSError:\n            LOGGER.error(\"Migration failed!\")\n            LOGGER.error(\"Old filename: {}\".format(old))\n            LOGGER.error(\"New filename: {}\".format(bot.tell_filename))\n            LOGGER.error(\n                \"See https://sopel.chat/usage/installing/upgrading-to-sopel-7/#reminder-db-migration\")\n        else:\n            LOGGER.info(\"Migration finished!\")\n    # End migration logic\n\n    if not os.path.exists(bot.tell_filename):\n        with io.open(bot.tell_filename, 'w', encoding='utf-8') as fd:\n            # if we can't open/write into the file, the tell plugin can't work\n            fd.write('')\n\n    if 'tell_lock' not in bot.memory:\n        bot.memory['tell_lock'] = threading.Lock()\n\n    if 'reminders' not in bot.memory:\n        with bot.memory['tell_lock']:\n            bot.memory['reminders'] = load_reminders(bot.tell_filename)\n\n\ndef shutdown(bot):\n    for key in ['tell_lock', 'reminders']:\n        try:\n            del bot.memory[key]\n        except KeyError:\n            pass\n\n\ndef _format_safe_lstrip(text):\n    \"\"\"``str.lstrip()`` but without eating IRC formatting.\n\n    :param str text: text to clean\n    :rtype: str\n    :raises TypeError: if the passed ``text`` is not a string\n\n    Stolen and tweaked from the ``choose`` plugin's ``_format_safe()``\n    function by the person who wrote it.\n    \"\"\"\n    if not isinstance(text, str):\n        raise TypeError(\"A string is required.\")\n    elif not text:\n        # unnecessary optimization\n        return ''\n\n    start = 0\n\n    # strip left\n    pos = 0\n    while pos < len(text):\n        is_whitespace = unicodedata.category(text[pos]) == 'Zs'\n        is_non_printing = (\n            text[pos] in formatting.CONTROL_NON_PRINTING and\n            text[pos] not in formatting.CONTROL_FORMATTING\n        )\n        if not is_whitespace and not is_non_printing:\n            start = pos\n            break\n        pos += 1\n    else:\n        # skipped everything; string is all whitespace\n        return ''\n\n    return text[start:]\n\n\n@plugin.command('tell', 'ask')\n@plugin.nickname_command('tell', 'ask')\n@plugin.example('$nickname, tell dgw he broke something again.')\ndef f_remind(bot, trigger):\n    \"\"\"Give someone a message the next time they're seen\"\"\"\n    teller = trigger.nick\n    verb = trigger.group(1)\n\n    if not trigger.group(3):\n        bot.reply(\"%s whom?\" % verb)\n        return\n\n    tellee = trigger.group(3).rstrip('.,:;')\n    msg = _format_safe_lstrip(trigger.group(2).split(' ', 1)[1])\n\n    if not msg:\n        bot.reply(\"%s %s what?\" % (verb, tellee))\n        return\n\n    tellee = tools.Identifier(tellee)\n\n    if not os.path.exists(bot.tell_filename):\n        return\n\n    if len(tellee) > bot.isupport.get('NICKLEN', 30):\n        bot.reply('That nickname is too long.')\n        return\n\n    if tellee[0] == '@':\n        tellee = tellee[1:]\n\n    if tellee == bot.nick:\n        bot.reply(\"I'm here now; you can %s me whatever you want!\" % verb)\n        return\n\n    if tellee not in (tools.Identifier(teller), bot.nick, 'me'):\n        tz = get_timezone(bot.db, bot.config, None, tellee)\n        timenow = format_time(bot.db, bot.config, tz, tellee)\n        with bot.memory['tell_lock']:\n            if tellee not in bot.memory['reminders']:\n                bot.memory['reminders'][tellee] = [(teller, verb, timenow, msg)]\n            else:\n                bot.memory['reminders'][tellee].append((teller, verb, timenow, msg))\n            # save the reminders\n            dump_reminders(bot.tell_filename, bot.memory['reminders'])\n\n        response = \"I'll pass that on when %s is around.\" % tellee\n        bot.reply(response)\n    elif tools.Identifier(teller) == tellee:\n        bot.reply('You can %s yourself that.' % verb)\n    else:\n        bot.reply(\"Hey, I'm not as stupid as Monty you know!\")\n\n\ndef get_nick_reminders(reminders, nick):\n    lines = []\n    template = \"%s: %s <%s> %s %s %s\"\n    today = time.strftime('%d %b', time.gmtime())\n\n    for (teller, verb, datetime, msg) in reminders:\n        if datetime.startswith(today):\n            datetime = datetime[len(today) + 1:]\n        lines.append(template % (nick, datetime, teller, verb, nick, msg))\n\n    return lines\n\n\ndef nick_match_tellee(nick, tellee):\n    \"\"\"Tell if a ``nick`` matches a ``tellee``.\n\n    :param str nick: Nick seen by the bot\n    :param str tellee: Tellee name or pattern\n\n    The check between ``nick`` and ``tellee`` is case-insensitive::\n\n        >>> nick_match_tellee('Exirel', 'exirel')\n        True\n        >>> nick_match_tellee('exirel', 'EXIREL')\n        True\n        >>> nick_match_tellee('exirel', 'dgw')\n        False\n\n    If ``tellee`` ends with a wildcard token (``*`` or ``:``), then ``nick``\n    matches if it starts with ``tellee`` (without the token)::\n\n        >>> nick_match_tellee('Exirel', 'Exi*')\n        True\n        >>> nick_match_tellee('Exirel', 'exi:')\n        True\n        >>> nick_match_tellee('Exirel', 'Exi')\n        False\n\n    Note that this is still case-insensitive.\n    \"\"\"\n    if tellee[-1] in ['*', ':']:  # these are wildcard token\n        return nick.lower().startswith(tellee.lower().rstrip('*:'))\n    return nick.lower() == tellee.lower()\n\n\n@plugin.rule('(.*)')\n@plugin.priority('low')\n@plugin.unblockable\n@plugin.output_prefix('[tell] ')\ndef message(bot, trigger):\n    nick = trigger.nick\n\n    if not os.path.exists(bot.tell_filename):\n        # plugin can't work without its storage file\n        return\n\n    # get all matching reminders\n    reminders = []\n    tellees = list(reversed(sorted(\n        tellee\n        for tellee in bot.memory['reminders']\n        if nick_match_tellee(nick, tellee)\n    )))\n\n    with bot.memory['tell_lock']:\n        # pop reminders for nick\n        reminders = list(\n            reminder\n            for tellee in tellees\n            for reminder in get_nick_reminders(\n                bot.memory['reminders'].pop(tellee, []), nick)\n        )\n\n    # check if there are reminders to send\n    if not reminders:\n        return  # nothing to do\n\n    # then send reminders (as public and/or private messages)\n    if bot.config.tell.use_private_reminder:\n        # send reminders with private messages\n        for line in reminders:\n            bot.say(line, nick)\n    else:\n        # send up to 'maximum_public' reminders to the channel\n        max_public = bot.config.tell.maximum_public\n        for line in reminders[:max_public]:\n            bot.say(line)\n\n        # send other reminders directly to nick as private message\n        if reminders[max_public:]:\n            bot.reply('Further messages sent privately')\n            for line in reminders[max_public:]:\n                bot.say(line, nick)\n\n    # save reminders left in memory\n    with bot.memory['tell_lock']:\n        dump_reminders(bot.tell_filename, bot.memory['reminders'])\n", "path": "sopel/modules/tell.py"}]}