lupaxa-chat-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. lupaxa_chat_server-0.1.0/.gitignore +30 -0
  2. lupaxa_chat_server-0.1.0/LICENCE +21 -0
  3. lupaxa_chat_server-0.1.0/PKG-INFO +120 -0
  4. lupaxa_chat_server-0.1.0/README.md +56 -0
  5. lupaxa_chat_server-0.1.0/docs/README.md +23 -0
  6. lupaxa_chat_server-0.1.0/pyproject.toml +117 -0
  7. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/__init__.py +7 -0
  8. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/__main__.py +8 -0
  9. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/admin.py +291 -0
  10. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/channel.py +257 -0
  11. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/cli.py +97 -0
  12. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/client.py +62 -0
  13. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/colour.py +44 -0
  14. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/commands.py +837 -0
  15. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/commands_list.py +159 -0
  16. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/config.py +179 -0
  17. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/defaults.py +10 -0
  18. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/entry.py +189 -0
  19. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/exceptions.py +15 -0
  20. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/globals.py +34 -0
  21. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/help.py +132 -0
  22. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/login.py +151 -0
  23. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/parse.py +123 -0
  24. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/password.py +284 -0
  25. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/player.py +33 -0
  26. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/rooms.py +551 -0
  27. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/saved_player.py +366 -0
  28. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/server.py +682 -0
  29. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/ssh.py +245 -0
  30. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/su.py +337 -0
  31. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/time.py +95 -0
  32. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/timer.py +145 -0
  33. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/utilities.py +381 -0
  34. lupaxa_chat_server-0.1.0/src/lupaxa/chat_server/version.py +10 -0
@@ -0,0 +1,30 @@
1
+ # OS
2
+ .DS_Store
3
+
4
+ # Cursor
5
+ .cursor/
6
+ .superpowers/
7
+ .worktrees/
8
+ cursor-docs
9
+
10
+ # Make
11
+ .makefiles/
12
+
13
+ # MkDocs
14
+ site/
15
+
16
+ # Players
17
+ config/players/
18
+
19
+ # Python
20
+ .venv/
21
+ __pycache__/
22
+ .mypy_cache/
23
+ .ruff_cache/
24
+ .pytest_cache/
25
+ dist/
26
+ build/
27
+ *.egg-info/
28
+
29
+ # SSH
30
+ config/ssh_host_key
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) The Lupaxa Project
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: lupaxa-chat-server
3
+ Version: 0.1.0
4
+ Summary: Multi-room TCP chat server.
5
+ Project-URL: Homepage, https://github.com/lupaxa-after-hours/chat-server
6
+ Project-URL: Documentation, https://chat-server.thelupaxaproject.org/
7
+ Project-URL: Repository, https://github.com/lupaxa-after-hours/chat-server
8
+ Project-URL: Issues, https://github.com/lupaxa-after-hours/chat-server/issues
9
+ Author: The Lupaxa Project
10
+ License: MIT License
11
+
12
+ Copyright (c) The Lupaxa Project
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENCE
32
+ Keywords: chat,server,tcp
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Environment :: Console
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Programming Language :: Python :: 3.14
44
+ Classifier: Topic :: Communications :: Chat
45
+ Requires-Python: >=3.10
46
+ Requires-Dist: asyncssh>=2.14
47
+ Requires-Dist: bcrypt>=4.0
48
+ Requires-Dist: colorama>=0.4.6
49
+ Requires-Dist: pyyaml>=6.0
50
+ Provides-Extra: dev
51
+ Requires-Dist: bump-my-version>=1.2.0; extra == 'dev'
52
+ Requires-Dist: hatch>=1.12.0; extra == 'dev'
53
+ Requires-Dist: mkdocs-material==9.7.7; extra == 'dev'
54
+ Requires-Dist: mkdocs==1.6.1; extra == 'dev'
55
+ Requires-Dist: mypy>=1.12.0; extra == 'dev'
56
+ Requires-Dist: pip-audit>=2.7.0; extra == 'dev'
57
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
58
+ Requires-Dist: pytest>=8.0; extra == 'dev'
59
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
60
+ Provides-Extra: test
61
+ Requires-Dist: pytest-cov>=5.0; extra == 'test'
62
+ Requires-Dist: pytest>=8.0; extra == 'test'
63
+ Description-Content-Type: text/markdown
64
+
65
+ <p align="center">
66
+ <a href="https://github.com/lupaxa-after-hours">
67
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/after-hours/readme-logo.png" alt="After Hours" />
68
+ </a>
69
+ </p>
70
+
71
+ <h1 align="center">Chat Server</h1>
72
+
73
+ Time for a little nostalgia, from the 1980s through the 2000s. Those years were full of
74
+ [talkers](https://en.wikipedia.org/wiki/Talker), [MUDs](https://en.wikipedia.org/wiki/Multi-user_dungeon) and
75
+ [MUSHes](https://en.wikipedia.org/wiki/MUSH): real-time chat and games, all of it text, reached by telnet or a
76
+ client made for the job. Most of them have long since left the internet, replaced by newer games and messaging.
77
+
78
+ This server is a throwback to that. The days we grew up online and spent playing these games.
79
+
80
+ It is a bare-bones chat system, and it works. Use it straight out of the box, or do what we used to do: tinker
81
+ with it, customise it, add to it, and make it your own.
82
+
83
+ A multi-room TCP chat server. Each player picks a name, starts in the
84
+ room named by `start`, and talks to the other people in that room.
85
+
86
+ ## Install
87
+
88
+ ```bash
89
+ pip install lupaxa-chat-server
90
+ ```
91
+
92
+ Requires Python 3.10+. PyYAML, bcrypt, and AsyncSSH are the runtime
93
+ dependencies.
94
+
95
+ ## Run
96
+
97
+ The server will not start without a config file. There is no default path.
98
+ From a checkout:
99
+
100
+ ```bash
101
+ chat-server -c config/config.yml
102
+ python -m lupaxa.chat_server -c config/config.yml
103
+ ```
104
+
105
+ The files under `config/` are one example. After
106
+ `pip install lupaxa-chat-server`, create the config, the rooms file, and
107
+ the help file yourself.
108
+
109
+ ## Documentation
110
+
111
+ The site source is in `mkdocs/`.
112
+
113
+ - [Getting Started](mkdocs/getting-started.md) covers the config file and how to run.
114
+ - [Connect](mkdocs/usage/index.md), [Players](mkdocs/usage/players.md), [Rooms](mkdocs/usage/rooms.md), and [Commands](mkdocs/usage/commands.md) are how a player uses it.
115
+ - [Reference](mkdocs/reference.md) is the config keys, ranks, and public names.
116
+ - [Examples](mkdocs/examples.md) is a short telnet session and the SSH command.
117
+
118
+ <a href="https://github.com/the-lupaxa-project">
119
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
120
+ </a>
@@ -0,0 +1,56 @@
1
+ <p align="center">
2
+ <a href="https://github.com/lupaxa-after-hours">
3
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/after-hours/readme-logo.png" alt="After Hours" />
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">Chat Server</h1>
8
+
9
+ Time for a little nostalgia, from the 1980s through the 2000s. Those years were full of
10
+ [talkers](https://en.wikipedia.org/wiki/Talker), [MUDs](https://en.wikipedia.org/wiki/Multi-user_dungeon) and
11
+ [MUSHes](https://en.wikipedia.org/wiki/MUSH): real-time chat and games, all of it text, reached by telnet or a
12
+ client made for the job. Most of them have long since left the internet, replaced by newer games and messaging.
13
+
14
+ This server is a throwback to that. The days we grew up online and spent playing these games.
15
+
16
+ It is a bare-bones chat system, and it works. Use it straight out of the box, or do what we used to do: tinker
17
+ with it, customise it, add to it, and make it your own.
18
+
19
+ A multi-room TCP chat server. Each player picks a name, starts in the
20
+ room named by `start`, and talks to the other people in that room.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install lupaxa-chat-server
26
+ ```
27
+
28
+ Requires Python 3.10+. PyYAML, bcrypt, and AsyncSSH are the runtime
29
+ dependencies.
30
+
31
+ ## Run
32
+
33
+ The server will not start without a config file. There is no default path.
34
+ From a checkout:
35
+
36
+ ```bash
37
+ chat-server -c config/config.yml
38
+ python -m lupaxa.chat_server -c config/config.yml
39
+ ```
40
+
41
+ The files under `config/` are one example. After
42
+ `pip install lupaxa-chat-server`, create the config, the rooms file, and
43
+ the help file yourself.
44
+
45
+ ## Documentation
46
+
47
+ The site source is in `mkdocs/`.
48
+
49
+ - [Getting Started](mkdocs/getting-started.md) covers the config file and how to run.
50
+ - [Connect](mkdocs/usage/index.md), [Players](mkdocs/usage/players.md), [Rooms](mkdocs/usage/rooms.md), and [Commands](mkdocs/usage/commands.md) are how a player uses it.
51
+ - [Reference](mkdocs/reference.md) is the config keys, ranks, and public names.
52
+ - [Examples](mkdocs/examples.md) is a short telnet session and the SSH command.
53
+
54
+ <a href="https://github.com/the-lupaxa-project">
55
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
56
+ </a>
@@ -0,0 +1,23 @@
1
+ <p align="center">
2
+ <a href="https://github.com/lupaxa-after-hours">
3
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/organisations/after-hours/readme-logo.png" alt="Organisation Logo" />
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">Core Documentation</h1>
8
+
9
+ The following documents define the standards that apply across **The Lupaxa Project**:
10
+
11
+ | Document | Purpose |
12
+ | :------------------------------------ | :---------------------------------------------------------------- |
13
+ | [Code of Conduct](CODE_OF_CONDUCT.md) | Expected behaviour and reporting procedures. |
14
+ | [How to Contribute](CONTRIBUTING.md) | Development workflow, coding standards, and contribution process. |
15
+ | [Governance](GOVERNANCE.md) | Project governance, roles, and decision-making. |
16
+ | [Maintainers](MAINTAINERS.md) | Maintainer responsibilities and expectations. |
17
+ | [Projects Overview](PROJECTS.md) | Overview of organisations, repositories, and project structure. |
18
+ | [Security Policy](SECURITY.md) | Responsible disclosure and secure development practices. |
19
+ | [Support Guide](SUPPORT.md) | How to obtain help and support. |
20
+
21
+ <a href="https://github.com/the-lupaxa-project">
22
+ <img src="https://raw.githubusercontent.com/the-lupaxa-project/brand-assets/master/logos/components/footer-for-child-orgs.svg" alt="The Lupaxa Project Footer" width="100%" />
23
+ </a>
@@ -0,0 +1,117 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lupaxa-chat-server"
7
+ dynamic = ["version"]
8
+ description = "Multi-room TCP chat server."
9
+ readme = "README.md"
10
+ license = { file = "LICENCE" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "asyncssh>=2.14",
14
+ "bcrypt>=4.0",
15
+ "colorama>=0.4.6",
16
+ "pyyaml>=6.0",
17
+ ]
18
+
19
+ authors = [
20
+ { name = "The Lupaxa Project" }
21
+ ]
22
+
23
+ keywords = ["chat", "tcp", "server"]
24
+
25
+ classifiers = [
26
+ "Development Status :: 3 - Alpha",
27
+ "Environment :: Console",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.10",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
37
+ "Topic :: Communications :: Chat",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/lupaxa-after-hours/chat-server"
42
+ Documentation = "https://chat-server.thelupaxaproject.org/"
43
+ Repository = "https://github.com/lupaxa-after-hours/chat-server"
44
+ Issues = "https://github.com/lupaxa-after-hours/chat-server/issues"
45
+
46
+ [project.scripts]
47
+ chat-server = "lupaxa.chat_server.cli:main"
48
+
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "pytest>=8.0",
52
+ "pytest-cov>=5.0",
53
+ "ruff>=0.6.0",
54
+ "mypy>=1.12.0",
55
+ "hatch>=1.12.0",
56
+ "bump-my-version>=1.2.0",
57
+ "pip-audit>=2.7.0",
58
+ "mkdocs==1.6.1",
59
+ "mkdocs-material==9.7.7",
60
+ ]
61
+ test = [
62
+ "pytest>=8.0",
63
+ "pytest-cov>=5.0",
64
+ ]
65
+
66
+ [tool.hatch.version]
67
+ path = "src/lupaxa/chat_server/version.py"
68
+
69
+ [tool.hatch.build.targets.sdist]
70
+ include = [
71
+ "src/**",
72
+ "README.md",
73
+ "LICENCE",
74
+ ]
75
+
76
+ [tool.hatch.build.targets.wheel]
77
+ packages = ["src/lupaxa"]
78
+
79
+ [tool.ruff]
80
+ line-length = 100
81
+ target-version = "py310"
82
+
83
+ [tool.ruff.lint]
84
+ select = ["E", "F", "I", "B", "S", "UP", "SIM", "C4", "ISC", "D", "ANN", "PT"]
85
+ ignore = [
86
+ "D200", "D201", "D202", "D203", "D204", "D205", "D206", "D207",
87
+ "D208", "D209", "D210", "D211", "D212",
88
+ ]
89
+
90
+ [tool.ruff.lint.per-file-ignores]
91
+ "tests/**/*.py" = ["S101", "ANN001", "ANN201"]
92
+ "src/lupaxa/chat_server/server.py" = ["S104"]
93
+
94
+ [tool.ruff.lint.pydocstyle]
95
+ convention = "numpy"
96
+
97
+ [tool.ruff.format]
98
+ quote-style = "double"
99
+
100
+ [tool.pyright]
101
+ typeCheckingMode = "standard"
102
+ enableTypeIgnoreComments = true
103
+ pythonVersion = "3.10"
104
+
105
+ [tool.mypy]
106
+ python_version = "3.10"
107
+ files = ["src", "tests"]
108
+ mypy_path = "src"
109
+ explicit_package_bases = true
110
+ namespace_packages = true
111
+ warn_unused_configs = true
112
+ ignore_missing_imports = true
113
+
114
+ [tool.pytest.ini_options]
115
+ addopts = "-ra -q --disable-warnings"
116
+ testpaths = ["tests"]
117
+ pythonpath = ["src"]
@@ -0,0 +1,7 @@
1
+ """Multi-room TCP chat server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .version import __version__, get_version
6
+
7
+ __all__ = ["__version__", "get_version"]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m lupaxa.chat_server`` to run the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,291 @@
1
+ """Commands only the admin can use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from .channel import broadcast_admin, broadcast_all, broadcast_staff, paint_admin, player_named
8
+ from .client import Client
9
+ from .colour import red
10
+ from .commands_list import COMMANDS, Command, Host
11
+ from .exceptions import ConfigError
12
+ from .globals import CANNOT_NOW
13
+ from .saved_player import (
14
+ RANK_ADMIN,
15
+ RANK_RESIDENT,
16
+ RANK_SUPER,
17
+ SavedPlayer,
18
+ rank_label,
19
+ save_player,
20
+ )
21
+ from .timer import shutdown_phrase
22
+ from .utilities import (
23
+ _is_admin,
24
+ _lookup,
25
+ _name_of,
26
+ center_block,
27
+ frame_width,
28
+ refuses_self,
29
+ )
30
+
31
+
32
+ def command_au(host: Host, client: Client, command: Command, text: str | None) -> None:
33
+ """Speak on the admin channel."""
34
+ del command
35
+ _admin_line(host, client, f"You say '{text}'", f"{_name_of(client)} says '{text}'")
36
+
37
+
38
+ def command_ae(host: Host, client: Client, command: Command, text: str | None) -> None:
39
+ """Emote on the admin channel."""
40
+ del command
41
+ _admin_line(host, client, f"You emote: {text}", f"{_name_of(client)} {text}")
42
+
43
+
44
+ def command_at(host: Host, client: Client, command: Command, text: str | None) -> None:
45
+ """Think on the admin channel."""
46
+ del command
47
+ _admin_line(
48
+ host,
49
+ client,
50
+ f"You think . o O ( {text} )",
51
+ f"{_name_of(client)} thinks . o O ( {text} )",
52
+ )
53
+
54
+
55
+ def _admin_line(host: Host, client: Client, mine: str, theirs: str) -> None:
56
+ """Send the sender ``mine`` and the rest of the admins ``theirs``."""
57
+ if not host.send(client, paint_admin(client, f"[ Admin ] {mine}\n")):
58
+ return
59
+ broadcast_admin(host, f"[ Admin ] {theirs}\n", skip=client)
60
+
61
+
62
+ def command_allow(host: Host, client: Client, command: Command, text: str | None) -> None:
63
+ """Open or close newbies or residents. The config file stays as it was."""
64
+ del command
65
+ parts = (text or "").strip().split()
66
+ if len(parts) != 2 or parts[0] not in ("newbies", "residents"):
67
+ host.send(client, "Syntax: allow newbies|residents on|off\n")
68
+ return
69
+ if parts[1] not in ("on", "off"):
70
+ host.send(client, "Syntax: allow newbies|residents on|off\n")
71
+ return
72
+ opened = parts[1] == "on"
73
+ if parts[0] == "newbies":
74
+ host._allow_newbies = opened
75
+ else:
76
+ host._allow_residents = opened
77
+ verb = "opens" if opened else "closes"
78
+ who = _name_of(client)
79
+ broadcast_staff(host, f"< SU > {who} {verb} the talker to {parts[0]}.\n")
80
+
81
+
82
+ def command_broadcast(host: Host, client: Client, command: Command, text: str | None) -> None:
83
+ """Send a centred broadcast message to every player who is in a room."""
84
+ del command
85
+ message = (text or "").strip()
86
+ if message == "":
87
+ host.send(client, "Syntax: broadcast <message>\n")
88
+ return
89
+ plain = f"-=> Broadcast Messages from {client.name}: {message} <=-"
90
+ for person in list(host._clients.values()):
91
+ if person.name is None or person.room is None:
92
+ continue
93
+ shown = red(plain) if person.player is not None and person.player.colour else plain
94
+ width = frame_width(person.discovered_columns, person.frame_columns)
95
+ padded = center_block(shown + "\n", width).removesuffix("\n")
96
+ host.send(person, f"\n\n\n{padded}\n\n\n\n")
97
+
98
+
99
+ def command_promote(host: Host, client: Client, command: Command, text: str | None) -> None:
100
+ """Make a saved resident a super user, or a super user an admin.
101
+
102
+ Any admin may promote to super user. Only the config admin may promote to admin.
103
+ The name must match in full.
104
+ """
105
+ del command
106
+ name = (text or "").strip()
107
+ if refuses_self(host, client, name):
108
+ return
109
+ if _is_admin(host, name):
110
+ host.send(client, f"{name} is the admin.\n")
111
+ return
112
+ found, saved = _lookup(host, client, name)
113
+ if not found:
114
+ return
115
+ if saved is None:
116
+ refusal = (
117
+ f"Player {name} not found.\n"
118
+ if player_named(host._clients.values(), name) is None
119
+ else f"{name} is not a resident.\n"
120
+ )
121
+ host.send(client, refusal)
122
+ return
123
+ if saved.rank == RANK_SUPER:
124
+ if not _is_admin(host, _name_of(client)):
125
+ host.send(
126
+ client,
127
+ f"Only the admin can promote {saved.name} to {rank_label(RANK_ADMIN)}.\n",
128
+ )
129
+ return
130
+ if _set_rank(host, client, saved, RANK_ADMIN):
131
+ label = rank_label(RANK_ADMIN)
132
+ broadcast_admin(
133
+ host,
134
+ f"[ Admin ] {_name_of(client)} promotes {saved.name} to {label}.\n",
135
+ )
136
+ return
137
+ if saved.rank == RANK_ADMIN:
138
+ host.send(client, f"{saved.name} is already an admin.\n")
139
+ return
140
+ if saved.rank != RANK_RESIDENT:
141
+ host.send(client, f"{name} is not a resident.\n")
142
+ return
143
+ if _set_rank(host, client, saved, RANK_SUPER):
144
+ broadcast_staff(
145
+ host,
146
+ f"< SU > {_name_of(client)} promotes {saved.name} to {rank_label(RANK_SUPER)}.\n",
147
+ )
148
+
149
+
150
+ def command_demote(host: Host, client: Client, command: Command, text: str | None) -> None:
151
+ """Move an admin or super user down one rank. ``force`` drops them to resident.
152
+
153
+ Any admin may demote a super user. Only the config admin may demote an admin.
154
+ """
155
+ parsed = _demote_args(text)
156
+ if parsed is None:
157
+ host.send(client, f"Syntax: {command.syntax}\n")
158
+ return
159
+ name, force = parsed
160
+ if refuses_self(host, client, name):
161
+ return
162
+ if _is_admin(host, name):
163
+ host.send(client, "The admin cannot be demoted.\n")
164
+ return
165
+ found, saved = _lookup(host, client, name)
166
+ if not found:
167
+ return
168
+ connected = player_named(host._clients.values(), name)
169
+ if saved is None and connected is None:
170
+ host.send(client, f"Player {name} not found.\n")
171
+ return
172
+ if saved is None or saved.rank not in (RANK_SUPER, RANK_ADMIN):
173
+ if saved is not None and saved.rank == RANK_RESIDENT:
174
+ host.send(client, f"{saved.name} is a resident.\n")
175
+ return
176
+ shown = connected.player.name if connected is not None and connected.player else name
177
+ host.send(client, f"{shown} cannot be demoted.\n")
178
+ return
179
+ if saved.rank == RANK_ADMIN and not _is_admin(host, _name_of(client)):
180
+ host.send(
181
+ client,
182
+ f"Only the admin can demote {saved.name} from {rank_label(RANK_ADMIN)}.\n",
183
+ )
184
+ return
185
+ if saved.rank == RANK_ADMIN and not force:
186
+ if _set_rank(host, client, saved, RANK_SUPER):
187
+ label = rank_label(RANK_ADMIN)
188
+ broadcast_admin(
189
+ host,
190
+ f"[ Admin ] {_name_of(client)} demotes {saved.name} from {label}.\n",
191
+ )
192
+ return
193
+ if not _set_rank(host, client, saved, RANK_RESIDENT):
194
+ return
195
+ if saved.rank == RANK_ADMIN:
196
+ line = f"{_name_of(client)} demotes {saved.name} from {rank_label(RANK_ADMIN)}.\n"
197
+ broadcast_admin(host, f"[ Admin ] {line}")
198
+ broadcast_staff(host, f"< SU > {line}")
199
+ return
200
+ broadcast_staff(
201
+ host,
202
+ f"< SU > {_name_of(client)} demotes {saved.name} from {rank_label(RANK_SUPER)}.\n",
203
+ )
204
+
205
+
206
+ def _demote_args(text: str | None) -> tuple[str, bool] | None:
207
+ """Return the name and whether ``force`` was set, or ``None`` when the words are wrong."""
208
+ parts = (text or "").strip().split()
209
+ if len(parts) == 1:
210
+ return parts[0], False
211
+ if len(parts) == 2 and parts[1].lower() == "force":
212
+ return parts[0], True
213
+ return None
214
+
215
+
216
+ def command_shutdown(host: Host, client: Client, command: Command, text: str | None) -> None:
217
+ """Start, replace, cancel, or finish the one server countdown."""
218
+ parts = (text or "").strip().split(None, 1)
219
+ if len(parts) < 2 or parts[1].strip() == "":
220
+ host.send(client, f"Syntax: {command.syntax}\n")
221
+ return
222
+ word, reason = parts[0], parts[1].strip()
223
+ if word == "now":
224
+ host._shutdown_seconds = None
225
+ host._shutdown_reason = ""
226
+ broadcast_all(host, f"Shutting down now for '{reason}'.\n", skip=None)
227
+ host.shutdown()
228
+ return
229
+ if word == "cancel":
230
+ if host._shutdown_seconds is None:
231
+ host.send(client, "No shutdown is running.\n")
232
+ return
233
+ host._shutdown_seconds = None
234
+ host._shutdown_reason = ""
235
+ broadcast_all(host, f"Shutdown cancelled for '{reason}'.\n", skip=None)
236
+ return
237
+ if not word.isascii() or not word.isdigit() or int(word) < 1:
238
+ host.send(client, f"Syntax: {command.syntax}\n")
239
+ return
240
+ seconds = int(word)
241
+ host._shutdown_seconds = seconds
242
+ host._shutdown_reason = reason
243
+ broadcast_all(host, shutdown_phrase(seconds, reason), skip=None)
244
+ if seconds == 1:
245
+ broadcast_all(host, "Shutting down now!\n", skip=None)
246
+
247
+
248
+ def _set_rank(host: Host, client: Client, saved: SavedPlayer, rank: str) -> bool:
249
+ """Write ``rank`` to the file of ``saved`` and to the live user, when connected."""
250
+ try:
251
+ save_player(host._roster_dir, replace(saved, rank=rank))
252
+ except (ConfigError, OSError) as exc:
253
+ print(exc)
254
+ host.send(client, CANNOT_NOW)
255
+ return False
256
+ target = player_named(host._clients.values(), saved.name)
257
+ if target is not None and target.player is not None:
258
+ target.player.rank = rank
259
+ return True
260
+
261
+
262
+ COMMANDS.extend(
263
+ (
264
+ Command(
265
+ "allow",
266
+ command_allow,
267
+ False,
268
+ "allow newbies|residents on|off",
269
+ rank=RANK_ADMIN,
270
+ ),
271
+ Command("broadcast", command_broadcast, False, "broadcast <message>", rank=RANK_ADMIN),
272
+ Command("promote", command_promote, False, "promote <name>", rank=RANK_ADMIN),
273
+ Command(
274
+ "demote",
275
+ command_demote,
276
+ False,
277
+ "demote <name>|demote <name> force",
278
+ rank=RANK_ADMIN,
279
+ ),
280
+ Command("ae", command_ae, False, "ae <message>", rank=RANK_ADMIN),
281
+ Command("at", command_at, False, "at <message>", rank=RANK_ADMIN),
282
+ Command("au", command_au, False, "au <message>", rank=RANK_ADMIN),
283
+ Command(
284
+ "shutdown",
285
+ command_shutdown,
286
+ False,
287
+ "shutdown <seconds> <reason>",
288
+ rank=RANK_ADMIN,
289
+ ),
290
+ )
291
+ )