diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index f7128c1c24..c118a61fd0 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -16,6 +16,7 @@ from evennia.accounts import bots from evennia.comms.channelhandler import CHANNELHANDLER from evennia.locks.lockhandler import LockException from evennia.utils import create, logger, utils, evtable +from evennia.utils.logger import tail_log_file from evennia.utils.utils import make_iter, class_from_module COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -25,6 +26,7 @@ CHANNEL_DEFAULT_TYPECLASS = class_from_module( # limit symbol import for API __all__ = ( + "CmdChannel", "CmdAddCom", "CmdDelCom", "CmdAllCom", @@ -44,31 +46,386 @@ __all__ = ( ) _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH +# helper functions to make it easier to override the main CmdChannel +# command and to keep the legacy addcom etc commands around. -def find_channel(caller, channelname, silent=False, noaliases=False): +def search_channel(caller, channelname, exact=False): """ - Helper function for searching for a single channel with - some error handling. + Helper function for searching for a single channel with some error + handling. + + Args: + channelname (str): Name, alias #dbref or partial name/alias to search + for. + exact (bool, optional): If an exact or fuzzy-match of the name should be done. + Note that even for a fuzzy match, an exactly given, unique channel name + will always be returned. + Returns: + list: A list of zero, one or more channels found. + + Notes: + No permission checks will be done here. + """ - channels = CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname) + # first see if this is a personal alias + channelname = caller.nicks.get(key=channelname, category="channel") or channelname + + # always try the exact match first. + channels = CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname, exact=True) + + if not channels and not exact: + # try fuzzy matching as well + channels = CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(channelname, exact=exact) + + + # check permissions + channels = [channel for channel in channels + if channel.access(caller, 'listen') or channel.access(caller, 'control')] + if not channels: - if not noaliases: - channels = [ - chan - for chan in CHANNEL_DEFAULT_TYPECLASS.objects.get_all_channels() - if channelname in chan.aliases.all() - ] - if channels: - return channels[0] - if not silent: - caller.msg("Channel '%s' not found." % channelname) - return None + return [] elif len(channels) > 1: - matches = ", ".join(["%s(%s)" % (chan.key, chan.id) for chan in channels]) - if not silent: - caller.msg("Multiple channels match (be more specific): \n%s" % matches) - return None - return channels[0] + return list(channels) + return [channels[0]] + + +def msg_channel(caller, channel, message, **kwargs): + """ + Send a message to a given channel. At this point + any permissions should already be done. + + Args: + caller (Object or Account): The entity sending the message. + channel (Channel): The channel to send to. + message (str): The message to send. + **kwargs: Unused by default. These kwargs will be passed into + all channel messaging hooks for custom overriding. + + """ + channel.msg(message, senders=caller, **kwargs) + + +def get_channel_history(caller, channel, start_index=0): + """ + View a channel's history. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to access. + message (str): The message to send. + **kwargs: Unused by default. These kwargs will be passed into + all channel messaging hooks for custom overriding. + + """ + log_file = channel.attributes.get( + "log_file", default=channel.log_file.format(channelkey=channel.key)) + + def send_msg(lines): + return caller.msg( + "".join(line.split("[-]", 1)[1] if "[-]" in line else line for line in lines) + ) + # asynchronously tail the log file + tail_log_file(log_file, start_index, 20, callback=send_msg) + +def sub_to_channel(caller, channel): + """ + Subscribe to a channel. Note that all permissions should + be checked before this step. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to access. + + Returns: + bool, str: True, None if connection failed. If False, + the second part is an error string. + + """ + if channel.has_connection(caller): + return False, f"Already listening to channel {channel.key}." + result = channel.connect(caller) + return result, "" if result else f"Were not allowed to subscribe to channel {channel.key}" + + +def unsub_to_channel(caller, channel): + """ + Un-Subscribe to a channel. Note that all permissions should + be checked before this step. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to unsub from. + + Returns: + bool, str: True, None if un-connection succeeded. If False, + the second part is an error string. + + """ + if not channel.has_connection(caller): + return False, f"Not listening to channel {channel.key}." + # clear nicks + chkey = channel.key + for nick in [ + nick + for nick in make_iter(caller.nicks.get(category="channel", return_obj=True)) + if nick and nick.pk and nick.value[3].lower() == chkey + ]: + nick.delete() + result = channel.disconnect(caller) + return result, "" if result else f"Could not unsubscribe from channel {channel.key}" + + +def add_alias(caller, channel, alias): + """ + Add a new alias for the user to use with this channel. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to alias. + alias (str): The personal alias to use for this channel. + + """ + caller.nicks.add(alias, channel.key, category="channel") + + +def remove_alias(caller, alias): + """ + Remove an alias from a given channel. + + Args: + caller (Object or Account): The entity performing the action. + alias (str, optional): The alias to remove, or `None` for all. + + Returns: + bool, str: True, None if removal succeeded. If False, + the second part is an error string. + + """ + channame = caller.nicks.get(key=alias, category="channel") + if channame: + caller.nicks.remove(key=alias, category="channel") + return True, "" + return False, "No such alias was defined." + + +def mute_channel(caller, channel): + """ + Temporarily mute a channel. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to alias. + + Returns: + bool, str: True, None if muting successful. If False, + the second part is an error string. + """ + if channel.mute(caller): + return True, "" + return False, f"Channel {channel.key} was already muted." + + +def unmute_channel(caller, channel): + """ + Unmute a channel. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to alias. + + Returns: + bool, str: True, None if unmuting successful. If False, + the second part is an error string. + + """ + if channel.unmute(caller): + return True, "" + return False, f"Channel {channel.key} was already unmuted." + + +def create_channel(caller, name, description, aliases=None): + """ + Create a new channel. Its name must not previously exist + (users can alias as needed). Will also connect to the + new channel. + + Args: + caller (Object or Account): The entity performing the action. + name (str): The new channel name/key. + description (str): This is used in listings. + aliases (list): A list of strings - alternative aliases for the channel + (not to be confused with per-user aliases; these are available for + everyone). + + Returns: + channel, str: new_channel, "" if creation successful. If False, + the second part is an error string. + + """ + if CHANNEL_DEFAULT_TYPECLASS.objects.channel_search(name, exact=True): + return False, f"Channel {name} already exists." + # set up the new channel + lockstring = "send:all();listen:all();control:id(%s)" % caller.id + new_chan = create.create_channel(name, aliases=aliases,desc=description, locks=lockstring) + new_chan.connect(caller) + return new_chan, "" + +def destroy_channel(caller, channel, message=None): + """ + Destroy an existing channel. Access should be checked before + calling this function. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to alias. + message (str, optional): Final message to send onto the channel + before destroying it. If not given, a default message is + used. Set to the empty string for no message. + + """ + channel_key = channel.key + if message is None: + message = (f"|rChannel {channel_key} is being destroyed. " + "Make sure to clean any channel aliases.|n") + if message: + channel.msg(message, senders=caller, bypass_mute=True) + channel.delete() + logger.log_sec( + "Channel {} was deleted by {}".format(channel_key, caller) + ) + + +def set_lock(caller, channel, lockstring): + """ + Set a lockstring on a channel. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to operate on. + lockstring (str): A lockstring on the form 'type:lockfunc();...' + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + try: + channel.locks.add(lockstring) + except LockException as err: + return False, err + return True, "" + + +def set_desc(caller, channel, description): + """ + Set a channel description. This is shown in listings etc. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to operate on. + description (str): A short description of the channel. + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + channel.db.desc = description + +def boot_user(caller, channel, target, quiet=False, reason=""): + """ + Boot a user from a channel, with optional reason. This will + also remove all their aliases for this channel. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to operate on. + target (Object or Account): The entity to boot. + quiet (bool, optional): Whether or not to announce to channel. + reason (str, optional): A reason for the boot. + + Returns: + bool, str: True, None if setting lock was successful. If False, + the second part is an error string. + + """ + if not channel.subscriptions.has(target): + return False, f"{target} is not connected to channel {channel.key}." + # find all of target's nicks linked to this channel and delete them + for nick in [ + nick + for nick in target.nicks.get(category="channel") or [] + if nick.value[3].lower() == channel.key + ]: + nick.delete() + channel.disconnect(target) + reason = f" Reason: {reason}" if reason else "" + target.msg(f"You were booted from channel {channel.key} by {caller.key}.{reason}") + if not quiet: + channel.msg(f"{target.key} was booted from channel by {caller.key}.{reason}") + + logger.log_sec(f"Channel Boot: {target} (Channel: {channel}, " + f"Reason: {reason}, Caller: {caller}") + + +def ban_user(caller, channel, target, quiet=False, reason=""): + """ + Ban a user from a channel, by locking them out. This will also + boot them, if they are currently connected. + + Args: + caller (Object or Account): The entity performing the action. + channel (Channel): The channel to operate on. + target (Object or Account): The entity to ban + quiet (bool, optional): Whether or not to announce to channel. + reason (str, optional): A reason for the ban + + """ + result, err = boot_user(caller, channel, target, quiet=quiet, reason=reason) + + + + +class CmdChannel(COMMAND_DEFAULT_CLASS): + """ + Talk on and manage in-game channels. + + Usage: + channel channelname [= ] + channel/history channelname [= index] + channel/sub channelname [= alias] + channel/unsub channelname[,channelname, ...] + channel/alias channelname = alias + channel/unalias channelname = alias + channel/mute channelname[,channelname,...] + channel/unmute channelname[,channelname,...] + channel/create channelname [= description] + channel/destroy channelname [: reason] + channel/lock channelname = lockstring + channel/desc channelname = description + channel/boot[/quiet] channelname = subscribername [: reason] + channel/who channelname + channel/list + channels + + This handles all operations on channels. + + """ + key = "channel" + aliases = ["chan", "channels"] + locks = "cmd: not pperm(channel_banned)" + switch_options = ( + "history", "sub", "unsub", "mute", "alias", "unalias", "create", + "destroy", "desc", "boot", "who") + + def parse(self): + super().parse() + self.channelnames = self.lhslist + + def func(self): + pass + + class CmdAddCom(COMMAND_DEFAULT_CLASS): diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index 785bb3899c..5e86f96c77 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -124,6 +124,10 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): def mutelist(self): return self.db.mute_list or [] + @property + def banlist(self): + return self.db.ban_list or [] + @property def wholist(self): subs = self.subscriptions.all() @@ -152,6 +156,10 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). + Returns: + bool: True if muting was successful, False if we were already + muted. + """ mutelist = self.mutelist if subscriber not in mutelist: @@ -162,7 +170,7 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): def unmute(self, subscriber, **kwargs): """ - Removes an entity to the list of muted subscribers. A muted subscriber + Removes an entity from the list of muted subscribers. A muted subscriber will no longer see channel messages, but may use channel commands. Args: @@ -170,11 +178,57 @@ class DefaultChannel(ChannelDB, metaclass=TypeclassBase): **kwargs (dict): Arbitrary, optional arguments for users overriding the call (unused by default). + Returns: + bool: True if unmuting was successful, False if we were already + unmuted. + """ mutelist = self.mutelist if subscriber in mutelist: mutelist.remove(subscriber) - self.db.mute_list = mutelist + return True + return False + + def ban(self, target, **kwargs): + """ + Ban a given user from connecting to the channel. This will not stop + users already connected, so the user must be booted for this to take + effect. + + Args: + target (Object or Account): The entity to unmute. This need not + be a subscriber. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if banning was successful, False if target was already + banned. + """ + banlist = self.banlist + if target not in banlist: + banlist.append(target) + return True + return False + + def unban(self, target, **kwargs): + """ + Un-Ban a given user. This will not reconnect them - they will still + have to reconnect and set up aliases anew. + + Args: + target (Object or Account): The entity to unmute. This need not + be a subscriber. + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + Returns: + bool: True if unbanning was successful, False if target was not + previously banned. + """ + banlist = self.banlist + if target not in banlist: + banlist.append(target) return True return False