Add is_restricted boolean check for commands
[mudpy.git] / mudpy / misc.py
1 """Miscellaneous functions for the mudpy engine."""
2
3 # Copyright (c) 2004-2020 mudpy authors. Permission to use, copy,
4 # modify, and distribute this software is granted under terms
5 # provided in the LICENSE file distributed with this software.
6
7 import codecs
8 import datetime
9 import os
10 import random
11 import re
12 import signal
13 import socket
14 import sys
15 import syslog
16 import time
17 import traceback
18 import unicodedata
19
20 import mudpy
21
22
23 class Element:
24
25     """An element of the universe."""
26
27     def __init__(self, key, universe, origin=None):
28         """Set up a new element."""
29
30         # keep track of our key name
31         self.key = key
32
33         # keep track of what universe it's loading into
34         self.universe = universe
35
36         # set of facet keys from the universe
37         self.facethash = dict()
38
39         # not owned by a user by default (used for avatars)
40         self.owner = None
41
42         # no contents in here by default
43         self.contents = {}
44
45         if self.key.find(".") > 0:
46             self.group, self.subkey = self.key.split(".")[-2:]
47         else:
48             self.group = "other"
49             self.subkey = self.key
50         if self.group not in self.universe.groups:
51             self.universe.groups[self.group] = {}
52
53         # get an appropriate origin
54         if not origin:
55             self.universe.add_group(self.group)
56             origin = self.universe.files[
57                     self.universe.origins[self.group]["fallback"]]
58
59         # record or reset a pointer to the origin file
60         self.origin = self.universe.files[origin.source]
61
62         # add or replace this element in the universe
63         self.universe.contents[self.key] = self
64         self.universe.groups[self.group][self.subkey] = self
65
66     def reload(self):
67         """Create a new element and replace this one."""
68         args = (self.key, self.universe, self.origin)
69         self.destroy()
70         Element(*args)
71
72     def destroy(self):
73         """Remove an element from the universe and destroy it."""
74         for facet in dict(self.facethash):
75             self.remove_facet(facet)
76         del self.universe.groups[self.group][self.subkey]
77         del self.universe.contents[self.key]
78         del self
79
80     def facets(self):
81         """Return a list of non-inherited facets for this element."""
82         return self.facethash
83
84     def has_facet(self, facet):
85         """Return whether the non-inherited facet exists."""
86         return facet in self.facets()
87
88     def remove_facet(self, facet):
89         """Remove a facet from the element."""
90         if ".".join((self.key, facet)) in self.origin.data:
91             del self.origin.data[".".join((self.key, facet))]
92         if facet in self.facethash:
93             del self.facethash[facet]
94         self.origin.modified = True
95
96     def ancestry(self):
97         """Return a list of the element's inheritance lineage."""
98         if self.has_facet("inherit"):
99             ancestry = self.get("inherit")
100             if not ancestry:
101                 ancestry = []
102             for parent in ancestry[:]:
103                 ancestors = self.universe.contents[parent].ancestry()
104                 for ancestor in ancestors:
105                     if ancestor not in ancestry:
106                         ancestry.append(ancestor)
107             return ancestry
108         else:
109             return []
110
111     def get(self, facet, default=None):
112         """Retrieve values."""
113         if default is None:
114             default = ""
115         try:
116             return self.origin.data[".".join((self.key, facet))]
117         except (KeyError, TypeError):
118             pass
119         if self.has_facet("inherit"):
120             for ancestor in self.ancestry():
121                 if self.universe.contents[ancestor].has_facet(facet):
122                     return self.universe.contents[ancestor].get(facet)
123         else:
124             return default
125
126     def set(self, facet, value):
127         """Set values."""
128         if not self.origin.is_writeable() and not self.universe.loading:
129             # break if there is an attempt to update an element from a
130             # read-only file, unless the universe is in the midst of loading
131             # updated data from files
132             raise PermissionError("Altering elements in read-only files is "
133                                   "disallowed")
134         # Coerce some values to appropriate data types
135         # TODO(fungi) Move these to a separate validation mechanism
136         if facet in ["loglevel"]:
137             value = int(value)
138         elif facet in ["administrator"]:
139             value = bool(value)
140
141         # The canonical node for this facet within its origin
142         node = ".".join((self.key, facet))
143
144         if node not in self.origin.data or self.origin.data[node] != value:
145             # Be careful to only update the origin's contents when required,
146             # since that affects whether the backing file gets written
147             self.origin.data[node] = value
148             self.origin.modified = True
149
150         # Make sure this facet is included in the element's facets
151         self.facethash[facet] = self.origin.data[node]
152
153     def append(self, facet, value):
154         """Append value to a list."""
155         newlist = self.get(facet)
156         if not newlist:
157             newlist = []
158         if type(newlist) is not list:
159             newlist = list(newlist)
160         newlist.append(value)
161         self.set(facet, newlist)
162
163     def send(
164         self,
165         message,
166         eol="$(eol)",
167         raw=False,
168         flush=False,
169         add_prompt=True,
170         just_prompt=False,
171         add_terminator=False,
172         prepend_padding=True
173     ):
174         """Convenience method to pass messages to an owner."""
175         if self.owner:
176             self.owner.send(
177                 message,
178                 eol,
179                 raw,
180                 flush,
181                 add_prompt,
182                 just_prompt,
183                 add_terminator,
184                 prepend_padding
185             )
186
187     def is_restricted(self):
188         """Boolean check whether command is administrative or debugging."""
189         return(self.get("administrative", False))
190
191     def is_admin(self):
192         """Boolean check whether an actor is controlled by an admin owner."""
193         return(self.owner and self.owner.is_admin())
194
195     def can_run(self, command):
196         """Check if the user can run this command object."""
197
198         # has to be in the commands group
199         if command not in self.universe.groups["command"].values():
200             return(False)
201
202         # avatars of administrators can run any command
203         if self.is_admin():
204             return(True)
205
206         # everyone can run non-administrative commands
207         if not command.is_restricted():
208             return(True)
209
210         # otherwise the command cannot be run by this actor
211         return(False)
212
213     def update_location(self):
214         """Make sure the location's contents contain this element."""
215         area = self.get("location")
216         if area in self.universe.contents:
217             self.universe.contents[area].contents[self.key] = self
218
219     def clean_contents(self):
220         """Make sure the element's contents aren't bogus."""
221         for element in self.contents.values():
222             if element.get("location") != self.key:
223                 del self.contents[element.key]
224
225     def go_to(self, area):
226         """Relocate the element to a specific area."""
227         current = self.get("location")
228         if current and self.key in self.universe.contents[current].contents:
229             del universe.contents[current].contents[self.key]
230         if area in self.universe.contents:
231             self.set("location", area)
232         self.universe.contents[area].contents[self.key] = self
233         self.look_at(area)
234
235     def go_home(self):
236         """Relocate the element to its default location."""
237         self.go_to(self.get("default_location"))
238         self.echo_to_location(
239             "You suddenly realize that " + self.get("name") + " is here."
240         )
241
242     def move_direction(self, direction):
243         """Relocate the element in a specified direction."""
244         motion = self.universe.contents["mudpy.movement.%s" % direction]
245         enter_term = motion.get("enter_term")
246         exit_term = motion.get("exit_term")
247         self.echo_to_location("%s exits %s." % (self.get("name"), exit_term))
248         self.send("You exit %s." % exit_term, add_prompt=False)
249         self.go_to(
250             self.universe.contents[
251                 self.get("location")].link_neighbor(direction)
252         )
253         self.echo_to_location("%s arrives from %s." % (
254             self.get("name"), enter_term))
255
256     def look_at(self, key):
257         """Show an element to another element."""
258         if self.owner:
259             element = self.universe.contents[key]
260             message = ""
261             name = element.get("name")
262             if name:
263                 message += "$(cyn)" + name + "$(nrm)$(eol)"
264             description = element.get("description")
265             if description:
266                 message += description + "$(eol)"
267             portal_list = list(element.portals().keys())
268             if portal_list:
269                 portal_list.sort()
270                 message += "$(cyn)[ Exits: " + ", ".join(
271                     portal_list
272                 ) + " ]$(nrm)$(eol)"
273             for element in self.universe.contents[
274                 self.get("location")
275             ].contents.values():
276                 if element.get("is_actor") and element is not self:
277                     message += "$(yel)" + element.get(
278                         "name"
279                     ) + " is here.$(nrm)$(eol)"
280                 elif element is not self:
281                     message += "$(grn)" + element.get(
282                         "impression"
283                     ) + "$(nrm)$(eol)"
284             self.send(message)
285
286     def portals(self):
287         """Map the portal directions for an area to neighbors."""
288         portals = {}
289         if re.match(r"""^area\.-?\d+,-?\d+,-?\d+$""", self.key):
290             coordinates = [(int(x))
291                            for x in self.key.split(".")[-1].split(",")]
292             offsets = dict(
293                 (x,
294                  self.universe.contents["mudpy.movement.%s" % x].get("vector")
295                  ) for x in self.universe.directions)
296             for portal in self.get("gridlinks"):
297                 adjacent = map(lambda c, o: c + o,
298                                coordinates, offsets[portal])
299                 neighbor = "area." + ",".join(
300                     [(str(x)) for x in adjacent]
301                 )
302                 if neighbor in self.universe.contents:
303                     portals[portal] = neighbor
304         for facet in self.facets():
305             if facet.startswith("link_"):
306                 neighbor = self.get(facet)
307                 if neighbor in self.universe.contents:
308                     portal = facet.split("_")[1]
309                     portals[portal] = neighbor
310         return portals
311
312     def link_neighbor(self, direction):
313         """Return the element linked in a given direction."""
314         portals = self.portals()
315         if direction in portals:
316             return portals[direction]
317
318     def echo_to_location(self, message):
319         """Show a message to other elements in the current location."""
320         for element in self.universe.contents[
321             self.get("location")
322         ].contents.values():
323             if element is not self:
324                 element.send(message)
325
326
327 class Universe:
328
329     """The universe."""
330
331     def __init__(self, filename="", load=False):
332         """Initialize the universe."""
333         self.groups = {}
334         self.contents = {}
335         self.directions = set()
336         self.loading = False
337         self.loglines = []
338         self.origins = {}
339         self.reload_flag = False
340         self.setup_loglines = []
341         self.startdir = os.getcwd()
342         self.terminate_flag = False
343         self.userlist = []
344         self.versions = None
345         if not filename:
346             possible_filenames = [
347                 "etc/mudpy.yaml",
348                 "/usr/local/mudpy/etc/mudpy.yaml",
349                 "/usr/local/etc/mudpy.yaml",
350                 "/etc/mudpy/mudpy.yaml",
351                 "/etc/mudpy.yaml"
352             ]
353             for filename in possible_filenames:
354                 if os.access(filename, os.R_OK):
355                     break
356         if not os.path.isabs(filename):
357             filename = os.path.join(self.startdir, filename)
358         self.filename = filename
359         if load:
360             # make sure to preserve any accumulated log entries during load
361             self.setup_loglines += self.load()
362
363     def load(self):
364         """Load universe data from persistent storage."""
365
366         # while loading, it's safe to update elements from read-only files
367         self.loading = True
368
369         # it's possible for this to enter before logging configuration is read
370         pending_loglines = []
371
372         # start populating the (re)files dict from the base config
373         self.files = {}
374         mudpy.data.Data(self.filename, self)
375
376         # load default storage locations for groups
377         if hasattr(self, "contents") and "mudpy.filing" in self.contents:
378             self.origins.update(self.contents["mudpy.filing"].get(
379                 "groups", {}))
380
381         # add some builtin groups we know we'll need
382         for group in ("account", "actor", "internal"):
383             self.add_group(group)
384
385         # make a list of inactive avatars
386         inactive_avatars = []
387         for account in self.groups.get("account", {}).values():
388             for avatar in account.get("avatars"):
389                 try:
390                     inactive_avatars.append(self.contents[avatar])
391                 except KeyError:
392                     pending_loglines.append((
393                         'Missing avatar "%s", possible data corruption' %
394                         avatar, 6))
395         for user in self.userlist:
396             if user.avatar in inactive_avatars:
397                 inactive_avatars.remove(user.avatar)
398
399         # another pass to straighten out all the element contents
400         for element in self.contents.values():
401             element.update_location()
402             element.clean_contents()
403
404         # done loading, so disallow updating elements from read-only files
405         self.loading = False
406
407         return pending_loglines
408
409     def new(self):
410         """Create a new, empty Universe (the Big Bang)."""
411         new_universe = Universe()
412         for attribute in vars(self).keys():
413             setattr(new_universe, attribute, getattr(self, attribute))
414         new_universe.reload_flag = False
415         del self
416         return new_universe
417
418     def save(self):
419         """Save the universe to persistent storage."""
420         for key in self.files:
421             self.files[key].save()
422
423     def initialize_server_socket(self):
424         """Create and open the listening socket."""
425
426         # need to know the local address and port number for the listener
427         host = self.contents["mudpy.network"].get("host")
428         port = self.contents["mudpy.network"].get("port")
429
430         # if no host was specified, bind to all local addresses (preferring
431         # ipv6)
432         if not host:
433             if socket.has_ipv6:
434                 host = "::"
435             else:
436                 host = "0.0.0.0"
437
438         # figure out if this is ipv4 or v6
439         family = socket.getaddrinfo(host, port)[0][0]
440         if family is socket.AF_INET6 and not socket.has_ipv6:
441             log("No support for IPv6 address %s (use IPv4 instead)." % host)
442
443         # create a new stream-type socket object
444         self.listening_socket = socket.socket(family, socket.SOCK_STREAM)
445
446         # set the socket options to allow existing open ones to be
447         # reused (fixes a bug where the server can't bind for a minute
448         # when restarting on linux systems)
449         self.listening_socket.setsockopt(
450             socket.SOL_SOCKET, socket.SO_REUSEADDR, 1
451         )
452
453         # bind the socket to to our desired server ipa and port
454         self.listening_socket.bind((host, port))
455
456         # disable blocking so we can proceed whether or not we can
457         # send/receive
458         self.listening_socket.setblocking(0)
459
460         # start listening on the socket
461         self.listening_socket.listen(1)
462
463         # note that we're now ready for user connections
464         log("Listening for Telnet connections on %s port %s" % (
465                 host, str(port)))
466
467     def get_time(self):
468         """Convenience method to get the elapsed time counter."""
469         return self.groups["internal"]["counters"].get("elapsed")
470
471     def add_group(self, group, fallback=None):
472         """Set up group tracking/metadata."""
473         if group not in self.origins:
474             self.origins[group] = {}
475         if not fallback:
476             fallback = mudpy.data.find_file(
477                     ".".join((group, "yaml")), universe=self)
478         if "fallback" not in self.origins[group]:
479             self.origins[group]["fallback"] = fallback
480         flags = self.origins[group].get("flags", None)
481         if fallback not in self.files:
482             mudpy.data.Data(fallback, self, flags=flags)
483
484
485 class User:
486
487     """This is a connected user."""
488
489     def __init__(self):
490         """Default values for the in-memory user variables."""
491         self.account = None
492         self.address = ""
493         self.authenticated = False
494         self.avatar = None
495         self.choice = ""
496         self.columns = 79
497         self.connection = None
498         self.error = ""
499         self.input_queue = []
500         self.last_address = ""
501         self.last_input = universe.get_time()
502         self.menu_choices = {}
503         self.menu_seen = False
504         self.negotiation_pause = 0
505         self.output_queue = []
506         self.partial_input = b""
507         self.password_tries = 0
508         self.rows = 23
509         self.state = "telopt_negotiation"
510         self.telopts = {}
511         self.ttype = None
512         self.universe = universe
513
514     def quit(self):
515         """Log, close the connection and remove."""
516         if self.account:
517             name = self.account.get("name", self)
518         else:
519             name = self
520         log("Logging out %s" % name, 2)
521         self.deactivate_avatar()
522         self.connection.close()
523         self.remove()
524
525     def check_idle(self):
526         """Warn or disconnect idle users as appropriate."""
527         idletime = universe.get_time() - self.last_input
528         linkdead_dict = universe.contents[
529             "mudpy.timing.idle.disconnect"].facets()
530         if self.state in linkdead_dict:
531             linkdead_state = self.state
532         else:
533             linkdead_state = "default"
534         if idletime > linkdead_dict[linkdead_state]:
535             self.send(
536                 "$(eol)$(red)You've done nothing for far too long... goodbye!"
537                 + "$(nrm)$(eol)",
538                 flush=True,
539                 add_prompt=False
540             )
541             logline = "Disconnecting "
542             if self.account and self.account.get("name"):
543                 logline += self.account.get("name")
544             else:
545                 logline += "an unknown user"
546             logline += (" after idling too long in the " + self.state
547                         + " state.")
548             log(logline, 2)
549             self.state = "disconnecting"
550             self.menu_seen = False
551         idle_dict = universe.contents["mudpy.timing.idle.warn"].facets()
552         if self.state in idle_dict:
553             idle_state = self.state
554         else:
555             idle_state = "default"
556         if idletime == idle_dict[idle_state]:
557             self.send(
558                 "$(eol)$(red)If you continue to be unproductive, "
559                 + "you'll be shown the door...$(nrm)$(eol)"
560             )
561
562     def reload(self):
563         """Save, load a new user and relocate the connection."""
564
565         # copy old attributes
566         attributes = self.__dict__
567
568         # get out of the list
569         self.remove()
570
571         # get rid of the old user object
572         del(self)
573
574         # create a new user object
575         new_user = User()
576
577         # set everything equivalent
578         new_user.__dict__ = attributes
579
580         # the avatar needs a new owner
581         if new_user.avatar:
582             new_user.account = universe.contents[new_user.account.key]
583             new_user.avatar = universe.contents[new_user.avatar.key]
584             new_user.avatar.owner = new_user
585
586         # add it to the list
587         universe.userlist.append(new_user)
588
589     def replace_old_connections(self):
590         """Disconnect active users with the same name."""
591
592         # the default return value
593         return_value = False
594
595         # iterate over each user in the list
596         for old_user in universe.userlist:
597
598             # the name is the same but it's not us
599             if hasattr(
600                old_user, "account"
601                ) and old_user.account and old_user.account.get(
602                 "name"
603             ) == self.account.get(
604                 "name"
605             ) and old_user is not self:
606
607                 # make a note of it
608                 log(
609                     "User " + self.account.get(
610                         "name"
611                     ) + " reconnected--closing old connection to "
612                     + old_user.address + ".",
613                     2
614                 )
615                 old_user.send(
616                     "$(eol)$(red)New connection from " + self.address
617                     + ". Terminating old connection...$(nrm)$(eol)",
618                     flush=True,
619                     add_prompt=False
620                 )
621
622                 # close the old connection
623                 old_user.connection.close()
624
625                 # replace the old connection with this one
626                 old_user.send(
627                     "$(eol)$(red)Taking over old connection from "
628                     + old_user.address + ".$(nrm)"
629                 )
630                 old_user.connection = self.connection
631                 old_user.last_address = old_user.address
632                 old_user.address = self.address
633
634                 # take this one out of the list and delete
635                 self.remove()
636                 del(self)
637                 return_value = True
638                 break
639
640         # true if an old connection was replaced, false if not
641         return return_value
642
643     def authenticate(self):
644         """Flag the user as authenticated and disconnect duplicates."""
645         if self.state != "authenticated":
646             self.authenticated = True
647             log("User %s authenticated for account %s." % (
648                     self, self.account.subkey), 2)
649             if ("mudpy.limit" in universe.contents and self.account.subkey in
650                     universe.contents["mudpy.limit"].get("admins")):
651                 self.account.set("administrator", True)
652                 log("Account %s is an administrator." % (
653                         self.account.subkey), 2)
654
655     def show_menu(self):
656         """Send the user their current menu."""
657         if not self.menu_seen:
658             self.menu_choices = get_menu_choices(self)
659             self.send(
660                 get_menu(self.state, self.error, self.menu_choices),
661                 "",
662                 add_terminator=True
663             )
664             self.menu_seen = True
665             self.error = False
666             self.adjust_echoing()
667
668     def prompt(self):
669         """"Generate and return an input prompt."""
670
671         # Start with the user's preference, if one was provided
672         prompt = self.account.get("prompt")
673
674         # If the user has not set a prompt, then immediately return the default
675         # provided for the current state
676         if not prompt:
677             return get_menu_prompt(self.state)
678
679         # Allow including the World clock state
680         if "$_(time)" in prompt:
681             prompt = prompt.replace(
682                 "$_(time)",
683                 str(universe.groups["internal"]["counters"].get("elapsed")))
684
685         # Append a single space for clear separation from user input
686         if prompt[-1] != " ":
687             prompt = "%s " % prompt
688
689         # Return the cooked prompt
690         return prompt
691
692     def adjust_echoing(self):
693         """Adjust echoing to match state menu requirements."""
694         if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_ECHO,
695                                    mudpy.telnet.US):
696             if menu_echo_on(self.state):
697                 mudpy.telnet.disable(self, mudpy.telnet.TELOPT_ECHO,
698                                      mudpy.telnet.US)
699         elif not menu_echo_on(self.state):
700             mudpy.telnet.enable(self, mudpy.telnet.TELOPT_ECHO,
701                                 mudpy.telnet.US)
702
703     def remove(self):
704         """Remove a user from the list of connected users."""
705         log("Disconnecting account %s." % self, 0)
706         universe.userlist.remove(self)
707
708     def send(
709         self,
710         output,
711         eol="$(eol)",
712         raw=False,
713         flush=False,
714         add_prompt=True,
715         just_prompt=False,
716         add_terminator=False,
717         prepend_padding=True
718     ):
719         """Send arbitrary text to a connected user."""
720
721         # unless raw mode is on, clean it up all nice and pretty
722         if not raw:
723
724             # strip extra $(eol) off if present
725             while output.startswith("$(eol)"):
726                 output = output[6:]
727             while output.endswith("$(eol)"):
728                 output = output[:-6]
729             extra_lines = output.find("$(eol)$(eol)$(eol)")
730             while extra_lines > -1:
731                 output = output[:extra_lines] + output[extra_lines + 6:]
732                 extra_lines = output.find("$(eol)$(eol)$(eol)")
733
734             # start with a newline, append the message, then end
735             # with the optional eol string passed to this function
736             # and the ansi escape to return to normal text
737             if not just_prompt and prepend_padding:
738                 if (not self.output_queue or not
739                         self.output_queue[-1].endswith(b"\r\n")):
740                     output = "$(eol)" + output
741                 elif not self.output_queue[-1].endswith(
742                     b"\r\n\x1b[0m\r\n"
743                 ) and not self.output_queue[-1].endswith(
744                     b"\r\n\r\n"
745                 ):
746                     output = "$(eol)" + output
747             output += eol + chr(27) + "[0m"
748
749             # tack on a prompt if active
750             if self.state == "active":
751                 if not just_prompt:
752                     output += "$(eol)"
753                 if add_prompt:
754                     output += self.prompt()
755                     mode = self.avatar.get("mode")
756                     if mode:
757                         output += "(" + mode + ") "
758
759             # find and replace macros in the output
760             output = replace_macros(self, output)
761
762             # wrap the text at the client's width (min 40, 0 disables)
763             if self.columns:
764                 if self.columns < 40:
765                     wrap = 40
766                 else:
767                     wrap = self.columns
768                 output = wrap_ansi_text(output, wrap)
769
770             # if supported by the client, encode it utf-8
771             if mudpy.telnet.is_enabled(self, mudpy.telnet.TELOPT_BINARY,
772                                        mudpy.telnet.US):
773                 encoded_output = output.encode("utf-8")
774
775             # otherwise just send ascii
776             else:
777                 encoded_output = output.encode("ascii", "replace")
778
779             # end with a terminator if requested
780             if add_prompt or add_terminator:
781                 if mudpy.telnet.is_enabled(
782                         self, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US):
783                     encoded_output += mudpy.telnet.telnet_proto(
784                         mudpy.telnet.IAC, mudpy.telnet.EOR)
785                 elif not mudpy.telnet.is_enabled(
786                         self, mudpy.telnet.TELOPT_SGA, mudpy.telnet.US):
787                     encoded_output += mudpy.telnet.telnet_proto(
788                         mudpy.telnet.IAC, mudpy.telnet.GA)
789
790             # and tack it onto the queue
791             self.output_queue.append(encoded_output)
792
793             # if this is urgent, flush all pending output
794             if flush:
795                 self.flush()
796
797         # just dump raw bytes as requested
798         else:
799             self.output_queue.append(output)
800             self.flush()
801
802     def pulse(self):
803         """All the things to do to the user per increment."""
804
805         # if the world is terminating, disconnect
806         if universe.terminate_flag:
807             self.state = "disconnecting"
808             self.menu_seen = False
809
810         # check for an idle connection and act appropriately
811         else:
812             self.check_idle()
813
814         # ask the client for their current terminal type (RFC 1091); it's None
815         # if it's not been initialized, the empty string if it has but the
816         # output was indeterminate, "UNKNOWN" if the client specified it has no
817         # terminal types to supply
818         if self.ttype is None:
819             mudpy.telnet.request_ttype(self)
820
821         # if output is paused, decrement the counter
822         if self.state == "telopt_negotiation":
823             if self.negotiation_pause:
824                 self.negotiation_pause -= 1
825             else:
826                 self.state = "entering_account_name"
827
828         # show the user a menu as needed
829         elif not self.state == "active":
830             self.show_menu()
831
832         # flush any pending output in the queue
833         self.flush()
834
835         # disconnect users with the appropriate state
836         if self.state == "disconnecting":
837             self.quit()
838
839         # check for input and add it to the queue
840         self.enqueue_input()
841
842         # there is input waiting in the queue
843         if self.input_queue:
844             handle_user_input(self)
845
846     def flush(self):
847         """Try to send the last item in the queue and remove it."""
848         if self.output_queue:
849             try:
850                 self.connection.send(self.output_queue[0])
851             except (BrokenPipeError, ConnectionResetError):
852                 if self.account and self.account.get("name"):
853                     account = self.account.get("name")
854                 else:
855                     account = "an unknown user"
856                 self.state = "disconnecting"
857                 log("Disconnected while sending to %s." % account, 7)
858             del self.output_queue[0]
859
860     def enqueue_input(self):
861         """Process and enqueue any new input."""
862
863         # check for some input
864         try:
865             raw_input = self.connection.recv(1024)
866         except OSError:
867             raw_input = b""
868
869         # we got something
870         if raw_input:
871
872             # tack this on to any previous partial
873             self.partial_input += raw_input
874
875             # reply to and remove any IAC negotiation codes
876             mudpy.telnet.negotiate_telnet_options(self)
877
878             # separate multiple input lines
879             new_input_lines = self.partial_input.split(b"\r\0")
880             if len(new_input_lines) == 1:
881                 new_input_lines = new_input_lines[0].split(b"\r\n")
882
883             # if input doesn't end in a newline, replace the
884             # held partial input with the last line of it
885             if not (
886                     self.partial_input.endswith(b"\r\0") or
887                     self.partial_input.endswith(b"\r\n")):
888                 self.partial_input = new_input_lines.pop()
889
890             # otherwise, chop off the extra null input and reset
891             # the held partial input
892             else:
893                 new_input_lines.pop()
894                 self.partial_input = b""
895
896             # iterate over the remaining lines
897             for line in new_input_lines:
898
899                 # strip off extra whitespace
900                 line = line.strip()
901
902                 # log non-printable characters remaining
903                 if not mudpy.telnet.is_enabled(
904                         self, mudpy.telnet.TELOPT_BINARY, mudpy.telnet.HIM):
905                     asciiline = bytes([x for x in line if 32 <= x <= 126])
906                     if line != asciiline:
907                         logline = "Non-ASCII characters from "
908                         if self.account and self.account.get("name"):
909                             logline += self.account.get("name") + ": "
910                         else:
911                             logline += "unknown user: "
912                         logline += repr(line)
913                         log(logline, 4)
914                         line = asciiline
915
916                 try:
917                     line = line.decode("utf-8")
918                 except UnicodeDecodeError:
919                     logline = "Non-UTF-8 sequence from "
920                     if self.account and self.account.get("name"):
921                         logline += self.account.get("name") + ": "
922                     else:
923                         logline += "unknown user: "
924                     logline += repr(line)
925                     log(logline, 4)
926                     return
927
928                 line = unicodedata.normalize("NFKC", line)
929
930                 # put on the end of the queue
931                 self.input_queue.append(line)
932
933     def new_avatar(self):
934         """Instantiate a new, unconfigured avatar for this user."""
935         counter = 0
936         while ("avatar_%s_%s" % (self.account.get("name"), counter)
937                 in universe.groups.get("actor", {}).keys()):
938             counter += 1
939         self.avatar = Element(
940             "actor.avatar_%s_%s" % (self.account.get("name"), counter),
941             universe)
942         self.avatar.append("inherit", "archetype.avatar")
943         self.account.append("avatars", self.avatar.key)
944         log("Created new avatar %s for user %s." % (
945                 self.avatar.key, self.account.get("name")), 0)
946
947     def delete_avatar(self, avatar):
948         """Remove an avatar from the world and from the user's list."""
949         if self.avatar is universe.contents[avatar]:
950             self.avatar = None
951         log("Deleting avatar %s for user %s." % (
952                 avatar, self.account.get("name")), 0)
953         universe.contents[avatar].destroy()
954         avatars = self.account.get("avatars")
955         avatars.remove(avatar)
956         self.account.set("avatars", avatars)
957
958     def activate_avatar_by_index(self, index):
959         """Enter the world with a particular indexed avatar."""
960         self.avatar = universe.contents[
961             self.account.get("avatars")[index]]
962         self.avatar.owner = self
963         self.state = "active"
964         log("Activated avatar %s (%s)." % (
965                 self.avatar.get("name"), self.avatar.key), 0)
966         self.avatar.go_home()
967
968     def deactivate_avatar(self):
969         """Have the active avatar leave the world."""
970         if self.avatar:
971             log("Deactivating avatar %s (%s) for user %s." % (
972                     self.avatar.get("name"), self.avatar.key,
973                     self.account.get("name")), 0)
974             current = self.avatar.get("location")
975             if current:
976                 self.avatar.set("default_location", current)
977                 self.avatar.echo_to_location(
978                     "You suddenly wonder where " + self.avatar.get(
979                         "name"
980                     ) + " went."
981                 )
982                 del universe.contents[current].contents[self.avatar.key]
983                 self.avatar.remove_facet("location")
984             self.avatar.owner = None
985             self.avatar = None
986
987     def destroy(self):
988         """Destroy the user and associated avatars."""
989         for avatar in self.account.get("avatars"):
990             self.delete_avatar(avatar)
991         log("Destroying account %s for user %s." % (
992                 self.account.get("name"), self), 0)
993         self.account.destroy()
994
995     def list_avatar_names(self):
996         """List names of assigned avatars."""
997         avatars = []
998         for avatar in self.account.get("avatars"):
999             try:
1000                 avatars.append(universe.contents[avatar].get("name"))
1001             except KeyError:
1002                 log('Missing avatar "%s", possible data corruption.' %
1003                     avatar, 6)
1004         return avatars
1005
1006     def is_admin(self):
1007         """Boolean check whether user's account is an admin."""
1008         return(self.account.get("administrator", False))
1009
1010
1011 def broadcast(message, add_prompt=True):
1012     """Send a message to all connected users."""
1013     for each_user in universe.userlist:
1014         each_user.send("$(eol)" + message, add_prompt=add_prompt)
1015
1016
1017 def log(message, level=0):
1018     """Log a message."""
1019
1020     # a couple references we need
1021     if "mudpy.log" in universe.contents:
1022         file_name = universe.contents["mudpy.log"].get("file", "")
1023         max_log_lines = universe.contents["mudpy.log"].get("lines", 0)
1024         syslog_name = universe.contents["mudpy.log"].get("syslog", "")
1025     else:
1026         file_name = ""
1027         max_log_lines = 0
1028         syslog_name = ""
1029     timestamp = datetime.datetime.now().isoformat(' ')
1030
1031     # turn the message into a list of nonempty lines
1032     lines = [x for x in [(x.rstrip()) for x in message.split("\n")] if x != ""]
1033
1034     # send the timestamp and line to a file
1035     if file_name:
1036         if not os.path.isabs(file_name):
1037             file_name = os.path.join(universe.startdir, file_name)
1038         os.makedirs(os.path.dirname(file_name), exist_ok=True)
1039         file_descriptor = codecs.open(file_name, "a", "utf-8")
1040         for line in lines:
1041             file_descriptor.write(timestamp + " " + line + "\n")
1042         file_descriptor.flush()
1043         file_descriptor.close()
1044
1045     # send the timestamp and line to standard output
1046     if ("mudpy.log" in universe.contents and
1047             universe.contents["mudpy.log"].get("stdout")):
1048         for line in lines:
1049             print(timestamp + " " + line)
1050
1051     # send the line to the system log
1052     if syslog_name:
1053         syslog.openlog(
1054             syslog_name.encode("utf-8"),
1055             syslog.LOG_PID,
1056             syslog.LOG_INFO | syslog.LOG_DAEMON
1057         )
1058         for line in lines:
1059             syslog.syslog(line)
1060         syslog.closelog()
1061
1062     # display to connected administrators
1063     for user in universe.userlist:
1064         if (
1065                 user.state == "active"
1066                 and user.is_admin()
1067                 and user.account.get("loglevel", 0) <= level):
1068             # iterate over every line in the message
1069             full_message = ""
1070             for line in lines:
1071                 full_message += (
1072                     "$(bld)$(red)" + timestamp + " "
1073                     + line.replace("$(", "$_(") + "$(nrm)$(eol)")
1074             user.send(full_message, flush=True)
1075
1076     # add to the recent log list
1077     for line in lines:
1078         while 0 < len(universe.loglines) >= max_log_lines:
1079             del universe.loglines[0]
1080         universe.loglines.append((level, timestamp + " " + line))
1081
1082
1083 def get_loglines(level, start, stop):
1084     """Return a specific range of loglines filtered by level."""
1085
1086     # filter the log lines
1087     loglines = [x for x in universe.loglines if x[0] >= level]
1088
1089     # we need these in several places
1090     total_count = str(len(universe.loglines))
1091     filtered_count = len(loglines)
1092
1093     # don't proceed if there are no lines
1094     if filtered_count:
1095
1096         # can't start before the beginning or at the end
1097         if start > filtered_count:
1098             start = filtered_count
1099         if start < 1:
1100             start = 1
1101
1102         # can't stop before we start
1103         if stop > start:
1104             stop = start
1105         elif stop < 1:
1106             stop = 1
1107
1108         # some preamble
1109         message = "There are " + str(total_count)
1110         message += " log lines in memory and " + str(filtered_count)
1111         message += " at or above level " + str(level) + "."
1112         message += " The matching lines from " + str(stop) + " to "
1113         message += str(start) + " are:$(eol)$(eol)"
1114
1115         # add the text from the selected lines
1116         if stop > 1:
1117             range_lines = loglines[-start:-(stop - 1)]
1118         else:
1119             range_lines = loglines[-start:]
1120         for line in range_lines:
1121             message += "   (" + str(line[0]) + ") " + line[1].replace(
1122                 "$(", "$_("
1123             ) + "$(eol)"
1124
1125     # there were no lines
1126     else:
1127         message = "None of the " + str(total_count)
1128         message += " lines in memory matches your request."
1129
1130     # pass it back
1131     return message
1132
1133
1134 def glyph_columns(character):
1135     """Convenience function to return the column width of a glyph."""
1136     if unicodedata.east_asian_width(character) in "FW":
1137         return 2
1138     else:
1139         return 1
1140
1141
1142 def wrap_ansi_text(text, width):
1143     """Wrap text with arbitrary width while ignoring ANSI colors."""
1144
1145     # the current position in the entire text string, including all
1146     # characters, printable or otherwise
1147     abs_pos = 0
1148
1149     # the current text position relative to the beginning of the line,
1150     # ignoring color escape sequences
1151     rel_pos = 0
1152
1153     # the absolute and relative positions of the most recent whitespace
1154     # character
1155     last_abs_whitespace = 0
1156     last_rel_whitespace = 0
1157
1158     # whether the current character is part of a color escape sequence
1159     escape = False
1160
1161     # normalize any potentially composited unicode before we count it
1162     text = unicodedata.normalize("NFKC", text)
1163
1164     # iterate over each character from the beginning of the text
1165     for each_character in text:
1166
1167         # the current character is the escape character
1168         if each_character == "\x1b" and not escape:
1169             escape = True
1170             rel_pos -= 1
1171
1172         # the current character is within an escape sequence
1173         elif escape:
1174             rel_pos -= 1
1175             if each_character == "m":
1176                 # the current character is m, which terminates the
1177                 # escape sequence
1178                 escape = False
1179
1180         # the current character is a space
1181         elif each_character == " ":
1182             last_abs_whitespace = abs_pos
1183             last_rel_whitespace = rel_pos
1184
1185         # the current character is a newline, so reset the relative
1186         # position too (start a new line)
1187         elif each_character == "\n":
1188             rel_pos = 0
1189             last_abs_whitespace = abs_pos
1190             last_rel_whitespace = rel_pos
1191
1192         # the current character meets the requested maximum line width, so we
1193         # need to wrap unless the current word is wider than the terminal (in
1194         # which case we let it do the wrapping instead)
1195         if last_rel_whitespace != 0 and (rel_pos > width or (
1196                 rel_pos > width - 1 and glyph_columns(each_character) == 2)):
1197
1198             # insert an eol in place of the last space
1199             text = (text[:last_abs_whitespace] + "\r\n" +
1200                     text[last_abs_whitespace + 1:])
1201
1202             # increase the absolute position because an eol is two
1203             # characters but the space it replaced was only one
1204             abs_pos += 1
1205
1206             # now we're at the beginning of a new line, plus the
1207             # number of characters wrapped from the previous line
1208             rel_pos -= last_rel_whitespace
1209             last_rel_whitespace = 0
1210
1211         # as long as the character is not a carriage return and the
1212         # other above conditions haven't been met, count it as a
1213         # printable character
1214         elif each_character != "\r":
1215             rel_pos += glyph_columns(each_character)
1216             if each_character in (" ", "\n"):
1217                 last_abs_whitespace = abs_pos
1218                 last_rel_whitespace = rel_pos
1219
1220         # increase the absolute position for every character
1221         abs_pos += 1
1222
1223     # return the newly-wrapped text
1224     return text
1225
1226
1227 def weighted_choice(data):
1228     """Takes a dict weighted by value and returns a random key."""
1229
1230     # this will hold our expanded list of keys from the data
1231     expanded = []
1232
1233     # create the expanded list of keys
1234     for key in data.keys():
1235         for _count in range(data[key]):
1236             expanded.append(key)
1237
1238     # return one at random
1239     # Allow the random.randrange() call in bandit since it's not used for
1240     # security/cryptographic purposes
1241     return random.choice(expanded)  # nosec
1242
1243
1244 def random_name():
1245     """Returns a random character name."""
1246
1247     # the vowels and consonants needed to create romaji syllables
1248     vowels = [
1249         "a",
1250         "i",
1251         "u",
1252         "e",
1253         "o"
1254     ]
1255     consonants = [
1256         "'",
1257         "k",
1258         "z",
1259         "s",
1260         "sh",
1261         "z",
1262         "j",
1263         "t",
1264         "ch",
1265         "ts",
1266         "d",
1267         "n",
1268         "h",
1269         "f",
1270         "m",
1271         "y",
1272         "r",
1273         "w"
1274     ]
1275
1276     # this dict will hold our weighted list of syllables
1277     syllables = {}
1278
1279     # generate the list with an even weighting
1280     for consonant in consonants:
1281         for vowel in vowels:
1282             syllables[consonant + vowel] = 1
1283
1284     # we'll build the name into this string
1285     name = ""
1286
1287     # create a name of random length from the syllables
1288     # Allow the random.randrange() call in bandit since it's not used for
1289     # security/cryptographic purposes
1290     for _syllable in range(random.randrange(2, 6)):  # nosec
1291         name += weighted_choice(syllables)
1292
1293     # strip any leading quotemark, capitalize and return the name
1294     return name.strip("'").capitalize()
1295
1296
1297 def replace_macros(user, text, is_input=False):
1298     """Replaces macros in text output."""
1299
1300     # third person pronouns
1301     pronouns = {
1302         "female": {"obj": "her", "pos": "hers", "sub": "she"},
1303         "male": {"obj": "him", "pos": "his", "sub": "he"},
1304         "neuter": {"obj": "it", "pos": "its", "sub": "it"}
1305     }
1306
1307     # a dict of replacement macros
1308     macros = {
1309         "eol": "\r\n",
1310         "bld": chr(27) + "[1m",
1311         "nrm": chr(27) + "[0m",
1312         "blk": chr(27) + "[30m",
1313         "blu": chr(27) + "[34m",
1314         "cyn": chr(27) + "[36m",
1315         "grn": chr(27) + "[32m",
1316         "mgt": chr(27) + "[35m",
1317         "red": chr(27) + "[31m",
1318         "yel": chr(27) + "[33m",
1319     }
1320
1321     # add dynamic macros where possible
1322     if user.account:
1323         account_name = user.account.get("name")
1324         if account_name:
1325             macros["account"] = account_name
1326     if user.avatar:
1327         avatar_gender = user.avatar.get("gender")
1328         if avatar_gender:
1329             macros["tpop"] = pronouns[avatar_gender]["obj"]
1330             macros["tppp"] = pronouns[avatar_gender]["pos"]
1331             macros["tpsp"] = pronouns[avatar_gender]["sub"]
1332
1333     # loop until broken
1334     while True:
1335
1336         # find and replace per the macros dict
1337         macro_start = text.find("$(")
1338         if macro_start == -1:
1339             break
1340         macro_end = text.find(")", macro_start) + 1
1341         macro = text[macro_start + 2:macro_end - 1]
1342         if macro in macros.keys():
1343             replacement = macros[macro]
1344
1345         # this is how we handle local file inclusion (dangerous!)
1346         elif macro.startswith("inc:"):
1347             incfile = mudpy.data.find_file(macro[4:], universe=universe)
1348             if os.path.exists(incfile):
1349                 incfd = codecs.open(incfile, "r", "utf-8")
1350                 replacement = ""
1351                 for line in incfd:
1352                     if line.endswith("\n") and not line.endswith("\r\n"):
1353                         line = line.replace("\n", "\r\n")
1354                     replacement += line
1355                 # lose the trailing eol
1356                 replacement = replacement[:-2]
1357             else:
1358                 replacement = ""
1359                 log("Couldn't read included " + incfile + " file.", 7)
1360
1361         # if we get here, log and replace it with null
1362         else:
1363             replacement = ""
1364             if not is_input:
1365                 log("Unexpected replacement macro " +
1366                     macro + " encountered.", 6)
1367
1368         # and now we act on the replacement
1369         text = text.replace("$(" + macro + ")", replacement)
1370
1371     # replace the look-like-a-macro sequence
1372     text = text.replace("$_(", "$(")
1373
1374     return text
1375
1376
1377 def escape_macros(value):
1378     """Escapes replacement macros in text."""
1379     if type(value) is str:
1380         return value.replace("$(", "$_(")
1381     else:
1382         return value
1383
1384
1385 def first_word(text, separator=" "):
1386     """Returns a tuple of the first word and the rest."""
1387     if text:
1388         if text.find(separator) > 0:
1389             return text.split(separator, 1)
1390         else:
1391             return text, ""
1392     else:
1393         return "", ""
1394
1395
1396 def on_pulse():
1397     """The things which should happen on each pulse, aside from reloads."""
1398
1399     # open the listening socket if it hasn't been already
1400     if not hasattr(universe, "listening_socket"):
1401         universe.initialize_server_socket()
1402
1403     # assign a user if a new connection is waiting
1404     user = check_for_connection(universe.listening_socket)
1405     if user:
1406         universe.userlist.append(user)
1407
1408     # iterate over the connected users
1409     for user in universe.userlist:
1410         user.pulse()
1411
1412     # add an element for counters if it doesn't exist
1413     if "counters" not in universe.groups.get("internal", {}):
1414         Element("internal.counters", universe)
1415
1416     # update the log every now and then
1417     if not universe.groups["internal"]["counters"].get("mark"):
1418         log(str(len(universe.userlist)) + " connection(s)")
1419         universe.groups["internal"]["counters"].set(
1420             "mark", universe.contents["mudpy.timing"].get("status")
1421         )
1422     else:
1423         universe.groups["internal"]["counters"].set(
1424             "mark", universe.groups["internal"]["counters"].get(
1425                 "mark"
1426             ) - 1
1427         )
1428
1429     # periodically save everything
1430     if not universe.groups["internal"]["counters"].get("save"):
1431         universe.save()
1432         universe.groups["internal"]["counters"].set(
1433             "save", universe.contents["mudpy.timing"].get("save")
1434         )
1435     else:
1436         universe.groups["internal"]["counters"].set(
1437             "save", universe.groups["internal"]["counters"].get(
1438                 "save"
1439             ) - 1
1440         )
1441
1442     # pause for a configurable amount of time (decimal seconds)
1443     time.sleep(universe.contents["mudpy.timing"].get("increment"))
1444
1445     # increase the elapsed increment counter
1446     universe.groups["internal"]["counters"].set(
1447         "elapsed", universe.groups["internal"]["counters"].get(
1448             "elapsed", 0
1449         ) + 1
1450     )
1451
1452
1453 def reload_data():
1454     """Reload all relevant objects."""
1455     universe.save()
1456     old_userlist = universe.userlist[:]
1457     old_loglines = universe.loglines[:]
1458     for element in list(universe.contents.values()):
1459         element.destroy()
1460     universe.load()
1461     new_loglines = universe.loglines[:]
1462     universe.loglines = old_loglines + new_loglines
1463     for user in old_userlist:
1464         user.reload()
1465
1466
1467 def check_for_connection(listening_socket):
1468     """Check for a waiting connection and return a new user object."""
1469
1470     # try to accept a new connection
1471     try:
1472         connection, address = listening_socket.accept()
1473     except BlockingIOError:
1474         return None
1475
1476     # note that we got one
1477     log("New connection from %s." % address[0], 2)
1478
1479     # disable blocking so we can proceed whether or not we can send/receive
1480     connection.setblocking(0)
1481
1482     # create a new user object
1483     user = User()
1484     log("Instantiated %s for %s." % (user, address[0]), 0)
1485
1486     # associate this connection with it
1487     user.connection = connection
1488
1489     # set the user's ipa from the connection's ipa
1490     user.address = address[0]
1491
1492     # let the client know we WILL EOR (RFC 885)
1493     mudpy.telnet.enable(user, mudpy.telnet.TELOPT_EOR, mudpy.telnet.US)
1494     user.negotiation_pause = 2
1495
1496     # return the new user object
1497     return user
1498
1499
1500 def find_command(command_name):
1501     """Try to find a command by name or abbreviation."""
1502
1503     # lowercase the command
1504     command_name = command_name.lower()
1505
1506     command = None
1507     if command_name in universe.groups["command"]:
1508         # the command matches a command word for which we have data
1509         command = universe.groups["command"][command_name]
1510     else:
1511         for candidate in sorted(universe.groups["command"]):
1512             if candidate.startswith(command_name) and not universe.groups[
1513                     "command"][candidate].is_restricted():
1514                 # the command matches the start of a command word and is not
1515                 # restricted to administrators
1516                 command = universe.groups["command"][candidate]
1517                 break
1518     return command
1519
1520
1521 def get_menu(state, error=None, choices=None):
1522     """Show the correct menu text to a user."""
1523
1524     # make sure we don't reuse a mutable sequence by default
1525     if choices is None:
1526         choices = {}
1527
1528     # get the description or error text
1529     message = get_menu_description(state, error)
1530
1531     # get menu choices for the current state
1532     message += get_formatted_menu_choices(state, choices)
1533
1534     # try to get a prompt, if it was defined
1535     message += get_menu_prompt(state)
1536
1537     # throw in the default choice, if it exists
1538     message += get_formatted_default_menu_choice(state)
1539
1540     # display a message indicating if echo is off
1541     message += get_echo_message(state)
1542
1543     # return the assembly of various strings defined above
1544     return message
1545
1546
1547 def menu_echo_on(state):
1548     """True if echo is on, false if it is off."""
1549     return universe.groups["menu"][state].get("echo", True)
1550
1551
1552 def get_echo_message(state):
1553     """Return a message indicating that echo is off."""
1554     if menu_echo_on(state):
1555         return ""
1556     else:
1557         return "(won't echo) "
1558
1559
1560 def get_default_menu_choice(state):
1561     """Return the default choice for a menu."""
1562     return universe.groups["menu"][state].get("default")
1563
1564
1565 def get_formatted_default_menu_choice(state):
1566     """Default menu choice foratted for inclusion in a prompt string."""
1567     default_choice = get_default_menu_choice(state)
1568     if default_choice:
1569         return "[$(red)" + default_choice + "$(nrm)] "
1570     else:
1571         return ""
1572
1573
1574 def get_menu_description(state, error):
1575     """Get the description or error text."""
1576
1577     # an error condition was raised by the handler
1578     if error:
1579
1580         # try to get an error message matching the condition
1581         # and current state
1582         description = universe.groups[
1583             "menu"][state].get("error_" + error)
1584         if not description:
1585             description = "That is not a valid choice..."
1586         description = "$(red)" + description + "$(nrm)"
1587
1588     # there was no error condition
1589     else:
1590
1591         # try to get a menu description for the current state
1592         description = universe.groups["menu"][state].get("description")
1593
1594     # return the description or error message
1595     if description:
1596         description += "$(eol)$(eol)"
1597     return description
1598
1599
1600 def get_menu_prompt(state):
1601     """Try to get a prompt, if it was defined."""
1602     prompt = universe.groups["menu"][state].get("prompt")
1603     if prompt:
1604         prompt += " "
1605     return prompt
1606
1607
1608 def get_menu_choices(user):
1609     """Return a dict of choice:meaning."""
1610     state = universe.groups["menu"][user.state]
1611     create_choices = state.get("create")
1612     if create_choices:
1613         choices = call_hook_function(create_choices, (user,))
1614     else:
1615         choices = {}
1616     ignores = []
1617     options = {}
1618     creates = {}
1619     for facet in state.facets():
1620         if facet.startswith("demand_") and not call_hook_function(
1621                 universe.groups["menu"][user.state].get(facet), (user,)):
1622             ignores.append(facet.split("_", 2)[1])
1623         elif facet.startswith("create_"):
1624             creates[facet] = facet.split("_", 2)[1]
1625         elif facet.startswith("choice_"):
1626             options[facet] = facet.split("_", 2)[1]
1627     for facet in creates.keys():
1628         if not creates[facet] in ignores:
1629             choices[creates[facet]] = call_hook_function(
1630                 state.get(facet), (user,))
1631     for facet in options.keys():
1632         if not options[facet] in ignores:
1633             choices[options[facet]] = state.get(facet)
1634     return choices
1635
1636
1637 def get_formatted_menu_choices(state, choices):
1638     """Returns a formatted string of menu choices."""
1639     choice_output = ""
1640     choice_keys = list(choices.keys())
1641     choice_keys.sort()
1642     for choice in choice_keys:
1643         choice_output += "   [$(red)" + choice + "$(nrm)]  " + choices[
1644             choice
1645         ] + "$(eol)"
1646     if choice_output:
1647         choice_output += "$(eol)"
1648     return choice_output
1649
1650
1651 def get_menu_branches(state):
1652     """Return a dict of choice:branch."""
1653     branches = {}
1654     for facet in universe.groups["menu"][state].facets():
1655         if facet.startswith("branch_"):
1656             branches[
1657                 facet.split("_", 2)[1]
1658             ] = universe.groups["menu"][state].get(facet)
1659     return branches
1660
1661
1662 def get_default_branch(state):
1663     """Return the default branch."""
1664     return universe.groups["menu"][state].get("branch")
1665
1666
1667 def get_choice_branch(user):
1668     """Returns the new state matching the given choice."""
1669     branches = get_menu_branches(user.state)
1670     if user.choice in branches.keys():
1671         return branches[user.choice]
1672     elif user.choice in user.menu_choices.keys():
1673         return get_default_branch(user.state)
1674     else:
1675         return ""
1676
1677
1678 def get_menu_actions(state):
1679     """Return a dict of choice:branch."""
1680     actions = {}
1681     for facet in universe.groups["menu"][state].facets():
1682         if facet.startswith("action_"):
1683             actions[
1684                 facet.split("_", 2)[1]
1685             ] = universe.groups["menu"][state].get(facet)
1686     return actions
1687
1688
1689 def get_default_action(state):
1690     """Return the default action."""
1691     return universe.groups["menu"][state].get("action")
1692
1693
1694 def get_choice_action(user):
1695     """Run any indicated script for the given choice."""
1696     actions = get_menu_actions(user.state)
1697     if user.choice in actions.keys():
1698         return actions[user.choice]
1699     elif user.choice in user.menu_choices.keys():
1700         return get_default_action(user.state)
1701     else:
1702         return ""
1703
1704
1705 def call_hook_function(fname, arglist):
1706     """Safely execute named function with supplied arguments, return result."""
1707
1708     # all functions relative to mudpy package
1709     function = mudpy
1710
1711     for component in fname.split("."):
1712         try:
1713             function = getattr(function, component)
1714         except AttributeError:
1715             log('Could not find mudpy.%s() for arguments "%s"'
1716                 % (fname, arglist), 7)
1717             function = None
1718             break
1719     if function:
1720         try:
1721             return function(*arglist)
1722         except Exception:
1723             log('Calling mudpy.%s(%s) raised an exception...\n%s'
1724                 % (fname, (*arglist,), traceback.format_exc()), 7)
1725
1726
1727 def handle_user_input(user):
1728     """The main handler, branches to a state-specific handler."""
1729
1730     # if the user's client echo is off, send a blank line for aesthetics
1731     if mudpy.telnet.is_enabled(user, mudpy.telnet.TELOPT_ECHO,
1732                                mudpy.telnet.US):
1733         user.send("", add_prompt=False, prepend_padding=False)
1734
1735     # check to make sure the state is expected, then call that handler
1736     try:
1737         globals()["handler_" + user.state](user)
1738     except KeyError:
1739         generic_menu_handler(user)
1740
1741     # since we got input, flag that the menu/prompt needs to be redisplayed
1742     user.menu_seen = False
1743
1744     # update the last_input timestamp while we're at it
1745     user.last_input = universe.get_time()
1746
1747
1748 def generic_menu_handler(user):
1749     """A generic menu choice handler."""
1750
1751     # get a lower-case representation of the next line of input
1752     if user.input_queue:
1753         user.choice = user.input_queue.pop(0)
1754         if user.choice:
1755             user.choice = user.choice.lower()
1756     else:
1757         user.choice = ""
1758     if not user.choice:
1759         user.choice = get_default_menu_choice(user.state)
1760     if user.choice in user.menu_choices:
1761         action = get_choice_action(user)
1762         if action:
1763             call_hook_function(action, (user,))
1764         new_state = get_choice_branch(user)
1765         if new_state:
1766             user.state = new_state
1767     else:
1768         user.error = "default"
1769
1770
1771 def handler_entering_account_name(user):
1772     """Handle the login account name."""
1773
1774     # get the next waiting line of input
1775     input_data = user.input_queue.pop(0)
1776
1777     # did the user enter anything?
1778     if input_data:
1779
1780         # keep only the first word and convert to lower-case
1781         name = input_data.lower()
1782
1783         # fail if there are non-alphanumeric characters
1784         if name != "".join(filter(
1785                 lambda x: x >= "0" and x <= "9" or x >= "a" and x <= "z",
1786                 name)):
1787             user.error = "bad_name"
1788
1789         # if that account exists, time to request a password
1790         elif name in universe.groups.get("account", {}):
1791             user.account = universe.groups["account"][name]
1792             user.state = "checking_password"
1793
1794         # otherwise, this could be a brand new user
1795         else:
1796             user.account = Element("account.%s" % name, universe)
1797             user.account.set("name", name)
1798             log("New user: " + name, 2)
1799             user.state = "checking_new_account_name"
1800
1801     # if the user entered nothing for a name, then buhbye
1802     else:
1803         user.state = "disconnecting"
1804
1805
1806 def handler_checking_password(user):
1807     """Handle the login account password."""
1808
1809     # get the next waiting line of input
1810     input_data = user.input_queue.pop(0)
1811
1812     if "mudpy.limit" in universe.contents:
1813         max_password_tries = universe.contents["mudpy.limit"].get(
1814             "password_tries", 3)
1815     else:
1816         max_password_tries = 3
1817
1818     # does the hashed input equal the stored hash?
1819     if mudpy.password.verify(input_data, user.account.get("passhash")):
1820
1821         # if so, set the username and load from cold storage
1822         if not user.replace_old_connections():
1823             user.authenticate()
1824             user.state = "main_utility"
1825
1826     # if at first your hashes don't match, try, try again
1827     elif user.password_tries < max_password_tries - 1:
1828         user.password_tries += 1
1829         user.error = "incorrect"
1830
1831     # we've exceeded the maximum number of password failures, so disconnect
1832     else:
1833         user.send(
1834             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1835         )
1836         user.state = "disconnecting"
1837
1838
1839 def handler_entering_new_password(user):
1840     """Handle a new password entry."""
1841
1842     # get the next waiting line of input
1843     input_data = user.input_queue.pop(0)
1844
1845     if "mudpy.limit" in universe.contents:
1846         max_password_tries = universe.contents["mudpy.limit"].get(
1847             "password_tries", 3)
1848     else:
1849         max_password_tries = 3
1850
1851     # make sure the password is strong--at least one upper, one lower and
1852     # one digit, seven or more characters in length
1853     if len(input_data) > 6 and len(
1854        list(filter(lambda x: x >= "0" and x <= "9", input_data))
1855        ) and len(
1856         list(filter(lambda x: x >= "A" and x <= "Z", input_data))
1857     ) and len(
1858         list(filter(lambda x: x >= "a" and x <= "z", input_data))
1859     ):
1860
1861         # hash and store it, then move on to verification
1862         user.account.set("passhash", mudpy.password.create(input_data))
1863         user.state = "verifying_new_password"
1864
1865     # the password was weak, try again if you haven't tried too many times
1866     elif user.password_tries < max_password_tries - 1:
1867         user.password_tries += 1
1868         user.error = "weak"
1869
1870     # too many tries, so adios
1871     else:
1872         user.send(
1873             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1874         )
1875         user.account.destroy()
1876         user.state = "disconnecting"
1877
1878
1879 def handler_verifying_new_password(user):
1880     """Handle the re-entered new password for verification."""
1881
1882     # get the next waiting line of input
1883     input_data = user.input_queue.pop(0)
1884
1885     if "mudpy.limit" in universe.contents:
1886         max_password_tries = universe.contents["mudpy.limit"].get(
1887             "password_tries", 3)
1888     else:
1889         max_password_tries = 3
1890
1891     # hash the input and match it to storage
1892     if mudpy.password.verify(input_data, user.account.get("passhash")):
1893         user.authenticate()
1894
1895         # the hashes matched, so go active
1896         if not user.replace_old_connections():
1897             user.state = "main_utility"
1898
1899     # go back to entering the new password as long as you haven't tried
1900     # too many times
1901     elif user.password_tries < max_password_tries - 1:
1902         user.password_tries += 1
1903         user.error = "differs"
1904         user.state = "entering_new_password"
1905
1906     # otherwise, sayonara
1907     else:
1908         user.send(
1909             "$(eol)$(red)Too many failed password attempts...$(nrm)$(eol)"
1910         )
1911         user.account.destroy()
1912         user.state = "disconnecting"
1913
1914
1915 def handler_active(user):
1916     """Handle input for active users."""
1917
1918     # get the next waiting line of input
1919     input_data = user.input_queue.pop(0)
1920
1921     # is there input?
1922     if input_data:
1923
1924         # split out the command and parameters
1925         actor = user.avatar
1926         mode = actor.get("mode")
1927         if mode and input_data.startswith("!"):
1928             command_name, parameters = first_word(input_data[1:])
1929         elif mode == "chat":
1930             command_name = "say"
1931             parameters = input_data
1932         else:
1933             command_name, parameters = first_word(input_data)
1934
1935         # expand to an actual command
1936         command = find_command(command_name)
1937
1938         # if it's allowed, do it
1939         result = None
1940         if actor.can_run(command):
1941             action_fname = command.get("action", command.key)
1942             if action_fname:
1943                 result = call_hook_function(action_fname, (actor, parameters))
1944
1945         # if the command was not run, give an error
1946         if not result:
1947             mudpy.command.error(actor, input_data)
1948
1949     # if no input, just idle back with a prompt
1950     else:
1951         user.send("", just_prompt=True)
1952
1953
1954 def daemonize(universe):
1955     """Fork and disassociate from everything."""
1956
1957     # only if this is what we're configured to do
1958     if "mudpy.process" in universe.contents and universe.contents[
1959             "mudpy.process"].get("daemon"):
1960
1961         # log before we start forking around, so the terminal gets the message
1962         log("Disassociating from the controlling terminal.")
1963
1964         # fork off and die, so we free up the controlling terminal
1965         if os.fork():
1966             os._exit(0)
1967
1968         # switch to a new process group
1969         os.setsid()
1970
1971         # fork some more, this time to free us from the old process group
1972         if os.fork():
1973             os._exit(0)
1974
1975         # reset the working directory so we don't needlessly tie up mounts
1976         os.chdir("/")
1977
1978         # clear the file creation mask so we can bend it to our will later
1979         os.umask(0)
1980
1981         # redirect stdin/stdout/stderr and close off their former descriptors
1982         for stdpipe in range(3):
1983             os.close(stdpipe)
1984         sys.stdin = codecs.open("/dev/null", "r", "utf-8")
1985         sys.__stdin__ = codecs.open("/dev/null", "r", "utf-8")
1986         sys.stdout = codecs.open("/dev/null", "w", "utf-8")
1987         sys.stderr = codecs.open("/dev/null", "w", "utf-8")
1988         sys.__stdout__ = codecs.open("/dev/null", "w", "utf-8")
1989         sys.__stderr__ = codecs.open("/dev/null", "w", "utf-8")
1990
1991
1992 def create_pidfile(universe):
1993     """Write a file containing the current process ID."""
1994     pid = str(os.getpid())
1995     log("Process ID: " + pid)
1996     if "mudpy.process" in universe.contents:
1997         file_name = universe.contents["mudpy.process"].get("pidfile", "")
1998     else:
1999         file_name = ""
2000     if file_name:
2001         if not os.path.isabs(file_name):
2002             file_name = os.path.join(universe.startdir, file_name)
2003         os.makedirs(os.path.dirname(file_name), exist_ok=True)
2004         file_descriptor = codecs.open(file_name, "w", "utf-8")
2005         file_descriptor.write(pid + "\n")
2006         file_descriptor.flush()
2007         file_descriptor.close()
2008
2009
2010 def remove_pidfile(universe):
2011     """Remove the file containing the current process ID."""
2012     if "mudpy.process" in universe.contents:
2013         file_name = universe.contents["mudpy.process"].get("pidfile", "")
2014     else:
2015         file_name = ""
2016     if file_name:
2017         if not os.path.isabs(file_name):
2018             file_name = os.path.join(universe.startdir, file_name)
2019         if os.access(file_name, os.W_OK):
2020             os.remove(file_name)
2021
2022
2023 def excepthook(excepttype, value, tracebackdata):
2024     """Handle uncaught exceptions."""
2025
2026     # assemble the list of errors into a single string
2027     message = "".join(
2028         traceback.format_exception(excepttype, value, tracebackdata)
2029     )
2030
2031     # try to log it, if possible
2032     try:
2033         log(message, 9)
2034     except Exception as e:
2035         # try to write it to stderr, if possible
2036         sys.stderr.write(message + "\nException while logging...\n%s" % e)
2037
2038
2039 def sighook(what, where):
2040     """Handle external signals."""
2041
2042     # a generic message
2043     message = "Caught signal: "
2044
2045     # for a hangup signal
2046     if what == signal.SIGHUP:
2047         message += "hangup (reloading)"
2048         universe.reload_flag = True
2049
2050     # for a terminate signal
2051     elif what == signal.SIGTERM:
2052         message += "terminate (halting)"
2053         universe.terminate_flag = True
2054
2055     # catchall for unexpected signals
2056     else:
2057         message += str(what) + " (unhandled)"
2058
2059     # log what happened
2060     log(message, 8)
2061
2062
2063 def override_excepthook():
2064     """Redefine sys.excepthook with our own."""
2065     sys.excepthook = excepthook
2066
2067
2068 def assign_sighook():
2069     """Assign a customized handler for some signals."""
2070     signal.signal(signal.SIGHUP, sighook)
2071     signal.signal(signal.SIGTERM, sighook)
2072
2073
2074 def setup():
2075     """This contains functions to be performed when starting the engine."""
2076
2077     # see if a configuration file was specified
2078     if len(sys.argv) > 1:
2079         conffile = sys.argv[1]
2080     else:
2081         conffile = ""
2082
2083     # the big bang
2084     global universe
2085     universe = Universe(conffile, True)
2086
2087     # report any loglines which accumulated during setup
2088     for logline in universe.setup_loglines:
2089         log(*logline)
2090     universe.setup_loglines = []
2091
2092     # fork and disassociate
2093     daemonize(universe)
2094
2095     # override the default exception handler so we get logging first thing
2096     override_excepthook()
2097
2098     # set up custom signal handlers
2099     assign_sighook()
2100
2101     # make the pidfile
2102     create_pidfile(universe)
2103
2104     # load and store diagnostic info
2105     universe.versions = mudpy.version.Versions("mudpy")
2106
2107     # log startup diagnostic messages
2108     log("On %s at %s" % (universe.versions.python_version, sys.executable), 1)
2109     log("Import path: %s" % ", ".join(sys.path), 1)
2110     log("Installed dependencies: %s" % universe.versions.dependencies_text, 1)
2111     log("Other python packages: %s" % universe.versions.environment_text, 1)
2112     log("Running version: %s" % universe.versions.version, 1)
2113     log("Initial directory: %s" % universe.startdir, 1)
2114     log("Command line: %s" % " ".join(sys.argv), 1)
2115
2116     # pass the initialized universe back
2117     return universe
2118
2119
2120 def finish():
2121     """These are functions performed when shutting down the engine."""
2122
2123     # the loop has terminated, so save persistent data
2124     universe.save()
2125
2126     # log a final message
2127     log("Shutting down now.")
2128
2129     # get rid of the pidfile
2130     remove_pidfile(universe)