diff --git a/docs/source/Components/Channels.md b/docs/source/Components/Channels.md index 29e050812e..8c9b0de6f8 100644 --- a/docs/source/Components/Channels.md +++ b/docs/source/Components/Channels.md @@ -315,6 +315,12 @@ gets its data from. A channel's log will rotate when it grows too big, which thus also automatically limits the max amount of history a user can view with `/history`. +The log file name is set on the channel class as the `log_file` property. This +is a string that takes the formatting token `{channelname}` to be replaced with +the (lower-case) name of the channel. By default the log is written to in the +channel's `at_post_channel_msg` method. + + ### Properties on Channels Channels have all the standard properties of a Typeclassed entity (`key`, @@ -323,16 +329,24 @@ see the [Channel api docs](api:evennia.comms.comms.DefaultChannel) for details. - `send_to_online_only` - this class boolean defaults to `True` and is a sensible optimization since people offline people will not see the message anyway. -- `log_to_file` - this is a string that determines the name of the channel log file. Default - is `"channel_{channel_key}.log"`. You should usually not change this. +- `log_file` - this is a string that determines the name of the channel log file. Default + is `"channel_{channelname}.log"`. The log file will appear in `settings.LOG_DIR` (usually + `mygame/server/logs/`). You should usually not change this. - `channel_prefix_string` - this property is a string to easily change how - the channel is prefixed. It takes the `channel_key` format key. Default is `"[{channel_key}] "` + the channel is prefixed. It takes the `channelname` format key. Default is `"[{channelname}] "` and produces output like `[public] ...``. - `subscriptions` - this is the [SubscriptionHandler](`api:evennia.comms.comms.SubscriptionHandler`), which has methods `has`, `add`, `remove`, `all`, `clear` and also `online` (to get only actually online channel-members). - `wholist`, `mutelist`, `banlist` are properties that return a list of subscribers, as well as who are currently muted or banned. +- `channel_msg_nick_pattern` - this is a regex pattern for performing the in-place nick + replacement (detect that `channelalias ` to `channel channelname = `. +- `remove_user_channel_alias(user, alias, **kwargs)` - remove an alias. Note that this is + a class-method that will happily remove found channel-aliases from the user linked to _any_ + channel, not only from the channel the method is called on. - `pre_join_channel(subscriber)` - if this returns `False`, connection will be refused. -- `post_join_channel(subscriber)` - unused by default. +- `post_join_channel(subscriber)` - by default this sets up a users's channel-nicks/aliases. - `pre_leave_channel(subscriber)` - if this returns `False`, the user is not allowed to leave. -- `post_leave_channel(subscriber)` - unused by default. +- `post_leave_channel(subscriber)` - this will clean up any channel aliases/nicks of the user. +- `delete` the standard typeclass-delete mechanism will also automatically un-subscribe all + subscribers (and thus wipe all their aliases). diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 81fef3718a..d162cec55e 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -12,6 +12,7 @@ from evennia.comms.models import Msg from evennia.accounts.models import AccountDB from evennia.accounts import bots from evennia.locks.lockhandler import LockException +from evennia.comms.comms import DefaultChannel from evennia.utils import create, logger, utils from evennia.utils.logger import tail_log_file from evennia.utils.utils import class_from_module @@ -229,17 +230,6 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): # disable this in child command classes if wanting on-character channels account_caller = True - # note - changing this will invalidate existing aliases in db - # channel_msg_nick_alias = r"{alias}\s*?(?P.+?){{0,1}}" - channel_msg_nick_alias = r"{alias}\s*?|{alias}\s+?(?P.+?)" - channel_msg_nick_replacement = "channel {channelname} = $1" - - # to make it easier to override help functionality, we add the ability to - # tweak access to different sub-functionality. Note that the system will - # still check control lock etc even if you can use this functionality. - # changing these does not change access to this command itself (that's the - # locks property) - def search_channel(self, channelname, exact=False, handle_errors=True): """ Helper function for searching for a single channel with some error @@ -323,9 +313,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): """ caller = self.caller - - log_file = channel.attributes.get( - "log_file", default=channel.log_to_file.format(channel_key=channel.key)) + log_file = channel.get_log_filename() def send_msg(lines): return self.msg( @@ -351,11 +339,9 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): if channel.has_connection(caller): return False, f"Already listening to channel {channel.key}." - result = channel.connect(caller) - key_and_aliases = [channel.key.lower()] + [alias.lower() for alias in channel.aliases.all()] - for key_or_alias in key_and_aliases: - self.add_alias(channel, key_or_alias) + # this sets up aliases in post_join_channel by default + result = channel.connect(caller) return result, "" if result else f"Were not allowed to subscribe to channel {channel.key}" @@ -377,14 +363,10 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): if not channel.has_connection(caller): return False, f"Not listening to channel {channel.key}." - # clear aliases - for key_or_alias in self.get_channel_aliases(channel): - self.remove_alias(key_or_alias, **kwargs) - # remove the channel-name alias too - msg_alias = self.channel_msg_nick_alias.format(alias=channel.key.lower()) - caller.nicks.remove(msg_alias, category="inputline", **kwargs) + # this will also clean aliases result = channel.disconnect(caller) + return result, "" if result else f"Could not unsubscribe from channel {channel.key}" def add_alias(self, channel, alias, **kwargs): @@ -401,7 +383,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): we need to be able to reference this channel easily. The other is a templated nick to easily be able to send messages to the channel without needing to give the full `channel` command. The - structure of this nick is given by `self.channel_msg_nick_alias` + structure of this nick is given by `self.channel_msg_pattern` and `self.channel_msg_nick_replacement`. By default it maps `alias -> channel = `, so that you can for example just write `pub Hello` to send a message. @@ -410,16 +392,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): for sending to channel using the main channel command. """ - chan_key = channel.key.lower() - # the message-pattern allows us to type the channel on its own without - # needing to use the `channel` command explicitly. - msg_pattern = self.channel_msg_nick_alias.format(alias=alias) - msg_replacement = self.channel_msg_nick_replacement.format(channelname=chan_key) - - if chan_key != alias: - self.caller.nicks.add(alias, chan_key, category="channel", **kwargs) - self.caller.nicks.add(msg_pattern, msg_replacement, category="inputline", - pattern_is_regex=True, **kwargs) + channel.add_user_channel_alias(self.caller, alias, **kwargs) def remove_alias(self, alias, **kwargs): """ @@ -440,13 +413,9 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): nick used for easily sending messages to the channel. """ - caller = self.caller - if caller.nicks.get(alias, category="channel", **kwargs): - caller.nicks.remove(alias, category="chan nel", **kwargs) - msg_alias = self.channel_msg_nick_alias.format(alias=alias) - caller.nicks.remove(msg_alias, category="inputline", **kwargs) + if self.caller.nicks.has(alias, category="channel", **kwargs): + DefaultChannel.remove_user_channel_alias(self.caller, alias) return True, "" - return False, "No such alias was defined." def get_channel_aliases(self, channel): @@ -462,7 +431,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): """ chan_key = channel.key.lower() - nicktuples = self.caller.nicks.get(category="channel", return_tuple=True) + nicktuples = self.caller.nicks.get(category="channel", return_tuple=True, return_list=True) if nicktuples: return [tup[2] for tup in nicktuples if tup[3].lower() == chan_key] return [] @@ -915,6 +884,8 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): # first 'channel name' is in fact 'channelname text' no_rhs_channel_name = self.args.split(" ", 1)[0] possible_lhs_message = self.args[len(no_rhs_channel_name):] + if possible_lhs_message.strip() == '=': + possible_lhs_message = "" channel_names.append(no_rhs_channel_name) @@ -952,13 +923,10 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): subscribed, available = self.list_channels() if channel in subscribed: table = self.display_subbed_channels([channel]) - inputname = self.raw_cmdname - if inputname.lower() != channel.key.lower(): - header = f"Channel |w{inputname}|n (alias for {channel.key} channel)" - else: - header = f"Channel |w{channel.key}|n" - self.msg(f"{header}\n(use |w{inputname} |n to chat and " - f"the 'channel' command to customize)\n{table}") + header = f"Channel |w{channel.key}|n" + self.msg(f"{header}\n(use |w{channel.key} |n (or a channel-alias) " + f"to chat and the 'channel' command " + f"to customize)\n{table}") elif channel in available: table = self.display_all_channels([], [channel]) self.msg( @@ -1055,7 +1023,7 @@ class CmdChannel(COMMAND_DEFAULT_CLASS): ask_yes_no( caller, prompt=f"Are you sure you want to delete channel '{channel.key}' " - "(make sure name is correct!)? This will disconnect and " + "(make sure name is correct!)?\nThis will disconnect and " "remove all users' aliases. {options}?", yes_action=_perform_delete, no_action="Aborted.", diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index 6acd8dff93..7a80f03d6a 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -21,12 +21,12 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): Class-level variables: - `send_to_online_only` (bool, default True) - if set, will only try to send to subscribers that are actually active. This is a useful optimization. - - `log_to_file` (str, default `"channel_{channel_key}.log"`). This is the - log file to which the channel history will be saved. The `{channel_key}` tag + - `log_file` (str, default `"channel_{channelname}.log"`). This is the + log file to which the channel history will be saved. The `{channelname}` tag will be replaced by the key of the Channel. If an Attribute 'log_file' is set, this will be used instead. If this is None and no Attribute is found, no history will be saved. - - `channel_prefix_string` (str, default `"[{channel_key} ]"`) - this is used + - `channel_prefix_string` (str, default `"[{channelname} ]"`) - this is used as a simple template to get the channel prefix with `.channel_prefix()`. """ @@ -40,10 +40,15 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): send_to_online_only = True # store log in log file. `channel_key tag will be replace with key of channel. # Will use log_file Attribute first, if given - log_to_file = "channel_{channel_key}.log" + log_file = "channel_{channelname}.log" # which prefix to use when showing were a message is coming from. Set to # None to disable and set this later. - channel_prefix_string = "[{channel_key}] " + channel_prefix_string = "[{channelname}] " + + # default nick-alias replacements (default using the 'channel' command) + channel_msg_nick_pattern = r"{alias}\s*?|{alias}\s+?(?P.+?)" + channel_msg_nick_replacement = "channel {channelname} = $1" + def at_first_save(self): """ @@ -54,7 +59,6 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): """ self.basetype_setup() self.at_channel_creation() - self.attributes.add("log_file", "channel_%s.log" % self.key) if hasattr(self, "_createdict"): # this is only set if the channel was created # with the utils.create.create_channel function. @@ -78,6 +82,10 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): def basetype_setup(self): self.locks.add("send:all();listen:all();control:perm(Admin)") + # make sure we don't have access to a same-named old channel's history. + log_file = self.get_log_filename() + logger.rotate_log_file(log_file, num_lines_to_append=0) + def at_channel_creation(self): """ Called once, when the channel is first created. @@ -87,6 +95,33 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): # helper methods, for easy overloading + _log_file = None + + def get_log_filename(self): + """ + File name to use for channel log. + + Returns: + str: The filename to use (this is always assumed to be inside + settings.LOG_DIR) + + """ + if not self._log_file: + self._log_file = self.attributes.get( + "log_file", self.log_file.format(channelname=self.key.lower())) + return self._log_file + + def set_log_filename(self, filename): + """ + Set a custom log filename. + + Args: + filename (str): The filename to set. This is a path starting from + inside the settings.LOG_DIR location. + + """ + self.attributes.add("log_file", filename) + def has_connection(self, subscriber): """ Checks so this account is actually listening @@ -368,6 +403,8 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): """ self.attributes.clear() self.aliases.clear() + for subscriber in self.subscriptions.all(): + self.disconnect(subscriber) super().delete() def channel_prefix(self): @@ -378,7 +415,73 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): str: The channel prefix. """ - return self.channel_prefix_string.format(channel_key=self.key) + return self.channel_prefix_string.format(channelname=self.key) + + def add_user_channel_alias(self, user, alias, **kwargs): + """ + Add a personal user-alias for this channel to a given subscriber. + + Args: + user (Object or Account): The one to alias this channel. + alias (str): The desired alias. + + Note: + This is tightly coupled to the default `channel` command. If you + change that, you need to change this as well. + + We add two nicks - one is a plain `alias -> channel.key` that + users need to be able to reference this channel easily. The other + is a templated nick to easily be able to send messages to the + channel without needing to give the full `channel` command. The + structure of this nick is given by `self.channel_msg_nick_pattern` + and `self.channel_msg_nick_replacement`. By default it maps + `alias -> channel = `, so that you can + for example just write `pub Hello` to send a message. + + The alias created is `alias $1 -> channel channel = $1`, to allow + for sending to channel using the main channel command. + + """ + chan_key = self.key.lower() + + # the message-pattern allows us to type the channel on its own without + # needing to use the `channel` command explicitly. + msg_nick_pattern = self.channel_msg_nick_pattern.format(alias=alias) + msg_nick_replacement = self.channel_msg_nick_replacement.format(channelname=chan_key) + user.nicks.add(msg_nick_pattern, msg_nick_replacement, category="inputline", + pattern_is_regex=True, **kwargs) + + if chan_key != alias: + # this allows for using the alias for general channel lookups + user.nicks.add(alias, chan_key, category="channel", **kwargs) + + @classmethod + def remove_user_channel_alias(cls, user, alias, **kwargs): + """ + Remove a personal channel alias from a user. + + Args: + user (Object or Account): The user to remove an alias from. + alias (str): The alias to remove. + **kwargs: Unused by default. Can be used to pass extra variables + into a custom implementation. + + Notes: + The channel-alias actually consists of two aliases - one + channel-based one for searching channels with the alias and one + inputline one for doing the 'channelalias msg' - call. + + This is a classmethod because it doesn't actually operate on the + channel instance. + + It sits on the channel because the nick structure for this is + pretty complex and needs to be located in a central place (rather + on, say, the channel command). + + """ + user.nicks.remove(alias, category="channel", **kwargs) + msg_nick_pattern = cls.channel_msg_nick_pattern.format(alias=alias) + user.nicks.remove(msg_nick_pattern, category="inputline", **kwargs) def at_pre_msg(self, message, **kwargs): """ @@ -472,9 +575,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): """ # save channel history to log file - default_log_file = (self.log_to_file.format(channel_key=self.key) - if self.log_to_file else None) - log_file = self.attributes.get("log_file", default=default_log_file) + log_file = self.get_log_filename() if log_file: senders = ",".join(sender.key for sender in kwargs.get("senders", [])) senders = f"{senders}: " if senders else "" @@ -506,8 +607,13 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). + Notes: + By default this adds the needed channel nicks to the joiner. + """ - pass + key_and_aliases = [self.key.lower()] + [alias.lower() for alias in self.aliases.all()] + for key_or_alias in key_and_aliases: + self.add_user_channel_alias(joiner, key_or_alias, **kwargs) def pre_leave_channel(self, leaver, **kwargs): """ @@ -535,7 +641,12 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): overriding the call (unused by default). """ - pass + chan_key = self.key.lower() + key_or_aliases = [self.key.lower()] + [alias.lower() for alias in self.aliases.all()] + nicktuples = leaver.nicks.get(category="channel", return_tuple=True, return_list=True) + key_or_aliases += [tup[2] for tup in nicktuples if tup[3].lower() == chan_key] + for key_or_alias in key_or_aliases: + self.remove_user_channel_alias(leaver, key_or_alias, **kwargs) def at_init(self): """ diff --git a/evennia/contrib/crafting/crafting.py b/evennia/contrib/crafting/crafting.py index 13260d268f..ab6283627c 100644 --- a/evennia/contrib/crafting/crafting.py +++ b/evennia/contrib/crafting/crafting.py @@ -186,7 +186,7 @@ class CraftingRecipeBase: are optional but will be passed into all of the following hooks. 2. `.pre_craft(**kwargs)` - this normally validates inputs and stores them in `.validated_inputs.`. Raises `CraftingValidationError` otherwise. - 4. `.craft(**kwargs)` - should return the crafted item(s) or the empty list. Any + 4. `.do_craft(**kwargs)` - should return the crafted item(s) or the empty list. Any crafting errors should be immediately reported to user. 5. `.post_craft(crafted_result, **kwargs)`- always called, even if `pre_craft` raised a `CraftingError` or `CraftingValidationError`. @@ -252,7 +252,7 @@ class CraftingRecipeBase: else: raise CraftingValidationError - def craft(self, **kwargs): + def do_craft(self, **kwargs): """ Hook to override. @@ -277,7 +277,7 @@ class CraftingRecipeBase: method is to delete the inputs. Args: - crafting_result (any): The outcome of crafting, as returned by `craft()`. + crafting_result (any): The outcome of crafting, as returned by `do_craft`. **kwargs: Any extra flags passed at initialization. Returns: @@ -324,7 +324,7 @@ class CraftingRecipeBase: if raise_exception: raise else: - craft_result = self.craft(**craft_kwargs) + craft_result = self.do_craft(**craft_kwargs) finally: craft_result = self.post_craft(craft_result, **craft_kwargs) except (CraftingError, CraftingValidationError): @@ -455,7 +455,7 @@ class CraftingRecipe(CraftingRecipeBase): 3. `.pre_craft(**kwargs)` should handle validation of inputs. Results should be stored in `validated_consumables/tools` respectively. Raises `CraftingValidationError` otherwise. - 4. `.craft(**kwargs)` will not be called if validation failed. Should return + 4. `.do_craft(**kwargs)` will not be called if validation failed. Should return a list of the things crafted. 5. `.post_craft(crafting_result, **kwargs)` is always called, also if validation failed (`crafting_result` will then be falsy). It does any cleanup. By default @@ -819,7 +819,7 @@ class CraftingRecipe(CraftingRecipeBase): self.validated_tools = tools self.validated_consumables = consumables - def craft(self, **kwargs): + def do_craft(self, **kwargs): """ Hook to override. This will not be called if validation in `pre_craft` fails. @@ -847,7 +847,7 @@ class CraftingRecipe(CraftingRecipeBase): this method is to delete the inputs. Args: - craft_result (list): The crafted result, provided by `self.craft()`. + craft_result (list): The crafted result, provided by `self.do_craft`. **kwargs (any): Passed from `self.craft`. Returns: diff --git a/evennia/contrib/crafting/example_recipes.py b/evennia/contrib/crafting/example_recipes.py index 7ca9c5ffd3..a7432d59bb 100644 --- a/evennia/contrib/crafting/example_recipes.py +++ b/evennia/contrib/crafting/example_recipes.py @@ -99,7 +99,7 @@ class _SwordSmithingBaseRecipe(CraftingRecipe): "You work and work but you are not happy with the result. You need to start over." ) - def do_craft(self, **kwargs): + def craft(self, **kwargs): """ Making a sword blade takes skill. Here we emulate this by introducing a random chance of failure (in a real game this could be a skill check @@ -126,7 +126,7 @@ class _SwordSmithingBaseRecipe(CraftingRecipe): if random.random() < 0.8: # 80% chance of success. This will spawn the sword and show # success-message. - return super().do_craft(**kwargs) + return super().craft(**kwargs) else: # fail and show failed message return None diff --git a/evennia/contrib/crafting/tests.py b/evennia/contrib/crafting/tests.py index 069a528431..2e87df1615 100644 --- a/evennia/contrib/crafting/tests.py +++ b/evennia/contrib/crafting/tests.py @@ -91,7 +91,7 @@ class TestCraftingRecipeBase(TestCase): """Test craft hook, the main access method.""" expected_result = _TestMaterial("test_result") - self.recipe.craft = mock.MagicMock(return_value=expected_result) + self.recipe.do_craft = mock.MagicMock(return_value=expected_result) self.assertTrue(self.recipe.allow_craft) @@ -99,7 +99,7 @@ class TestCraftingRecipeBase(TestCase): # check result self.assertEqual(result, expected_result) - self.recipe.craft.assert_called_with(kw1=1, kw2=2) + self.recipe.do_craft.assert_called_with(kw1=1, kw2=2) # since allow_reuse is False, this usage should now be turned off self.assertFalse(self.recipe.allow_craft) @@ -110,7 +110,7 @@ class TestCraftingRecipeBase(TestCase): def test_craft_hook__fail(self): """Test failing the call""" - self.recipe.craft = mock.MagicMock(return_value=None) + self.recipe.do_craft = mock.MagicMock(return_value=None) # trigger exception with self.assertRaises(crafting.CraftingError): @@ -213,7 +213,7 @@ class TestCraftingRecipe(TestCase): self.assertIsNotNone(self.tool1.pk) self.assertIsNotNone(self.tool2.pk) - def test_seed__succcess(self): + def test_seed__success(self): """Test seed helper classmethod""" # needed for other dbs to pass seed diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index ec85744afc..529e981203 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -995,7 +995,8 @@ class AttributeHandler: looked-after Attribute. default_access (bool, optional): If no `attrread` lock is set on object, this determines if the lock should then be passed or not. - return_list (bool, optional): + return_list (bool, optional): Always return a list, also if there is only + one or zero matches found. Returns: result (any or list): One or more matches for keys and/or diff --git a/evennia/utils/logger.py b/evennia/utils/logger.py index 7997327ab0..33a21f41f8 100644 --- a/evennia/utils/logger.py +++ b/evennia/utils/logger.py @@ -357,12 +357,14 @@ class EvenniaLogFile(logfile.LogFile): _CHANNEL_LOG_NUM_TAIL_LINES = settings.CHANNEL_LOG_NUM_TAIL_LINES num_lines_to_append = _CHANNEL_LOG_NUM_TAIL_LINES - def rotate(self): + def rotate(self, num_lines_to_append=None): """ Rotates our log file and appends some number of lines from the previous log to the start of the new one. """ - append_tail = self.num_lines_to_append > 0 + append_tail = (num_lines_to_append + if num_lines_to_append is not None + else self.num_lines_to_append) if not append_tail: logfile.LogFile.rotate(self) return @@ -472,6 +474,43 @@ def log_file(msg, filename="game.log"): deferToThread(callback, filehandle, msg).addErrback(errback) +def log_file_exists(filename="game.log"): + """ + Determine if a log-file already exists. + + Args: + filename (str): The filename (within the log-dir). + + Returns: + bool: If the log file exists or not. + + """ + global _LOGDIR + if not _LOGDIR: + from django.conf import settings + _LOGDIR = settings.LOG_DIR + + filename = os.path.join(_LOGDIR, filename) + return os.path.exists(filename) + + +def rotate_log_file(filename="game.log", num_lines_to_append=None): + """ + Force-rotate a log-file, without + + Args: + filename (str): The log file, located in settings.LOG_DIR. + num_lines_to_append (int, optional): Include N number of + lines from previous file in new one. If `None`, use default. + Set to 0 to include no lines. + + """ + if log_file_exists(filename): + file_handle = _open_log_file(filename) + if file_handle: + file_handle.rotate(num_lines_to_append=num_lines_to_append) + + def tail_log_file(filename, offset, nlines, callback=None): """ Return the tail of the log file.