Move alias/connect setup to channel class rather than cmd

This commit is contained in:
Griatch 2021-05-14 20:28:36 +02:00
parent 8e19017dc3
commit 51bef9bf97
8 changed files with 224 additions and 84 deletions

View file

@ -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 <msg` means that you want to send a message to a channel).
This pattern accepts an `{alias}` formatting marker. Don't mess with this unless you really
want to change how channels work.
- `channel_msg_nick_replacement` - this is a string on the [nick replacement
- form](Nicks). It accepts the `{channelname}` formatting tag. This is strongly tied to the
`channel` command and is by default `channel {channelname} = $1`.
Notable `Channel` hooks:
@ -347,12 +361,19 @@ Notable `Channel` hooks:
also just remove that call.
- every channel message. By default it just returns `channel_prefix_string`.
- `has_connection(subscriber)` - shortcut to check if an entity subscribes to
this channel
this channel.
- `mute/unmute(subscriber)` - this mutes the channel for this user.
- `ban/unban(subscriber)` - adds/remove user from banlist.
- `connect/disconnect(subscriber)` - adds/removes a subscriber.
- `add_user_channel_alias(user, alias, **kwargs)` - sets up a user-nick for this channel. This is
what maps e.g. `alias <msg>` to `channel channelname = <msg>`.
- `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).

View file

@ -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<arg1>.+?){{0,1}}"
channel_msg_nick_alias = r"{alias}\s*?|{alias}\s+?(?P<arg1>.+?)"
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 <msg> -> channel <channelname> = <msg>`, 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} <msg>|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} <msg>|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.",

View file

@ -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<arg1>.+?)"
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 <msg> -> channel <channelname> = <msg>`, 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):
"""

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.