commit 472f0812e77dc311f55ca4681f9eb4dc595b5d40 Author: wwevo Date: Fri Nov 21 07:26:02 2025 +0100 Release 0.9.0 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..51fe20c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://paypal.me/wwevo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75f4186 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +/bot/options/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Local environment +.env + +# Logs +*.log +gunicorn.pid +*.pid +/bot/modules/storage/storage.json diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..d16e866 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,25 @@ +# Project Guidelines + +* Antwort bitte maximal 3 Sätze, keine Nachfragen. +* Keine Status‑Updates, nur direkte Antwort. +* Nichts ausführen/keine Tools, nur [CHAT]-Antwort. +* Keine Code‑Änderungen ohne meine explizite Freigabe. +* Vor jeder längeren Antwort erst Rückfrage stellen. +* Jeden Plan bestätigen lassen. + +## Projektüberblick (kurz) +Folgende Dateien und Ordner gehören NICHT zum Projekt und werden niemals durchsucht: +``` +/venv/* +``` + +Folgende Datein und Ordner sind für dich read-only. Da wird nichts bearbeitet, gelöscht oder anderweitig verändert, es sei denn, ich bitte dich darum die dokumentation anzupassen. +``` +/bot/resources/* +``` + +- chrani_bot_tng ist ein modularer Python‑Bot mit Web‑UI (LCARS‑Stil) und Telnet‑Anbindung, u. a. für Server‑/Spielumgebungen. +- Einstiegspunkte: `app.py` (lokal) und `wsgi.py` (WSGI/Gunicorn); Module unter `bot/modules/*` (z. B. telnet, webserver, players, locations, permissions). +- HTML/Jinja2‑Templates und Widgets liegen in den jeweiligen Modul‑Ordnern; statische Assets unter `bot/modules/webserver/static`. +- Informational Resources in `bot/resources/*.md`. + \ No newline at end of file diff --git a/.run/app.run.xml b/.run/app.run.xml new file mode 100644 index 0000000..9b1c71f --- /dev/null +++ b/.run/app.run.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/.run/gunicorn-dev.run.xml b/.run/gunicorn-dev.run.xml new file mode 100644 index 0000000..490c0b0 --- /dev/null +++ b/.run/gunicorn-dev.run.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/.run/gunicorn.run.xml b/.run/gunicorn.run.xml new file mode 100644 index 0000000..48c812e --- /dev/null +++ b/.run/gunicorn.run.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/DOM_HELPER_MIGRATION.md b/DOM_HELPER_MIGRATION.md new file mode 100644 index 0000000..52c800b --- /dev/null +++ b/DOM_HELPER_MIGRATION.md @@ -0,0 +1,337 @@ +# DOM Helper Migration Plan + +## Step 1: Add Helpers to Module Base Class + +**File:** `bot/module.py` + +Add two methods to Module class: + +```python +def get_element_from_dom(self, module_name, dataset, owner, identifier=None): + """ + Get full element from DOM. + + Args: + module_name: e.g. "module_locations" + dataset: active dataset name + owner: owner steamid + identifier: element identifier (optional for owner-level access) + + Returns: + Full element dict from DOM + """ + path_data = ( + self.dom.data + .get(module_name, {}) + .get("elements", {}) + .get(dataset, {}) + .get(owner, {}) + ) + + if identifier: + return path_data.get(identifier, {}) + + return path_data + + +def update_element(self, module_name, dataset, owner, identifier, updates, dispatchers_steamid=None): + """ + Update element fields in DOM. + + Args: + module_name: e.g. "module_locations" + dataset: active dataset name + owner: owner steamid + identifier: element identifier + updates: dict with fields to update + dispatchers_steamid: who triggered update + + Example: + module.update_element("module_locations", dataset, owner, "Lobby", + {"is_enabled": True}) + """ + # Get current element + element = self.get_element_from_dom(module_name, dataset, owner, identifier) + + # Merge updates + element.update(updates) + + # Write back full element + self.dom.data.upsert({ + module_name: { + "elements": { + dataset: { + owner: { + identifier: element + } + } + } + } + }, dispatchers_steamid=dispatchers_steamid, min_callback_level=4, max_callback_level=5) + + +def get_element_from_callback(self, updated_values_dict, matched_path): + """ + Get full element from DOM based on callback data. + + Args: + updated_values_dict: Dict from callback kwargs + matched_path: Pattern with wildcards like "module_x/elements/%dataset%/%owner%/%id%" + + Returns: + Full element dict from DOM + + Notes: + - matched_path contains wildcards (e.g., %owner_steamid%) + - Wildcards are placeholders, not actual values + - Actual values come from updated_values_dict structure + - Works with any depth (4, 5, 6+) + """ + parts = matched_path.split('/') + + if 'elements' not in parts or len(parts) < 4: + return {} + + active_dataset = self.dom.data.get("module_game_environment", {}).get("active_dataset") + module_name = parts[0] + + # Depth 5+: module/elements/dataset/owner/identifier[/property] + # Structure: {identifier: {data}} + if len(parts) >= 5: + identifier = list(updated_values_dict.keys())[0] + element_dict = updated_values_dict[identifier] + owner = element_dict.get("owner") + + # Get full element (ignoring property path if depth > 5) + return self.get_element_from_dom(module_name, active_dataset, owner, identifier) + + # Depth 4: module/elements/dataset/owner + # Structure: {owner: {...}} + elif len(parts) == 4: + owner = list(updated_values_dict.keys())[0] + return self.get_element_from_dom(module_name, active_dataset, owner) + + return {} +``` + +## Step 2: Update All Actions + +**Find all actions:** +```bash +grep -r "module.dom.data.upsert" bot/modules/*/actions/*.py +``` + +**Pattern - OLD:** +```python +module.dom.data.upsert({ + "module_locations": { + "elements": { + location_origin: { + location_owner: { + location_identifier: { + "is_enabled": element_is_enabled + } + } + } + } + } +}, dispatchers_steamid=dispatchers_steamid, min_callback_level=4) +``` + +**Pattern - NEW:** +```python +active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset") + +module.update_element( + "module_locations", + active_dataset, + location_owner, + location_identifier, + {"is_enabled": element_is_enabled}, + dispatchers_steamid=dispatchers_steamid +) +``` + +**Actions checklist:** +- [ ] `locations/actions/toggle_enabled_flag.py` +- [ ] `locations/actions/edit_location.py` (keep as-is, already writes full element) +- [ ] `players/actions/update_player_permission_level.py` +- [ ] `players/actions/toggle_player_mute.py` +- [ ] `dom_management/actions/select.py` +- [ ] All other actions with partial upserts + +## Step 3: Update All Handlers + +**Find all handlers:** +```bash +python3 scripts/analyze_callback_usage.py +``` + +**Pattern - OLD:** +```python +def handler(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + + for identifier, data in updated_values_dict.items(): + owner = data.get("owner") + # Complex DOM lookups... + full_element = module.dom.data.get(...)... +``` + +**Pattern - NEW:** +```python +def handler(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + matched_path = kwargs.get("matched_path", "") + + for identifier, data in updated_values_dict.items(): + # Get full element + element = module.get_element_from_callback( + {identifier: data}, + matched_path + ) + + # Use directly + owner = element.get("owner") + name = element.get("name") + # ... +``` + +**Handlers checklist:** +- [ ] `locations/widgets/manage_locations_widget.py::table_row` +- [ ] `locations/widgets/manage_locations_widget.py::update_location_on_map` +- [ ] `locations/widgets/manage_locations_widget.py::update_selection_status` +- [ ] `locations/widgets/manage_locations_widget.py::update_enabled_flag` +- [ ] `players/widgets/manage_players_widget.py::table_rows` +- [ ] `players/widgets/manage_players_widget.py::update_widget` +- [ ] `game_environment/widgets/location_change_announcer_widget.py::announce_location_change` + +## Step 4: Testing + +**For each file:** +1. Update code +2. Restart bot +3. Trigger action/handler +4. Check logs for errors +5. Verify UI updates + +**Automated check:** +```bash +# No more complex upserts: +grep -r "\"elements\":" bot/modules/*/actions/*.py | wc -l +# Should be 0 or very few (only edit_location type actions) + +# No more DOM fallback in handlers: +grep -r "\.get.*\.get.*\.get.*\.get" bot/modules/*/widgets/*.py | wc -l +# Should be much lower +``` + +## Step 5: Validate Wildcards and Browser Updates + +**Critical check - wildcards MUST work:** + +Handler registration uses wildcards: +```python +"module_locations/elements/%map_identifier%/%owner_steamid%/%element_identifier%": handler +``` + +Callback system passes `matched_path` with wildcards intact. +Helper `get_element_from_callback()` MUST parse this correctly. + +**Test each wildcard pattern:** + +```bash +# Test location handler (depth 5): +# Pattern: module_locations/elements/%map%/%owner%/%id% +# Edit location → check handler receives correct element + +# Test player handler (depth 4): +# Pattern: module_players/elements/%map%/%steamid% +# Update player → check handler receives correct element + +# Test enabled flag (depth 6): +# Pattern: module_locations/elements/%map%/%owner%/%id%/is_enabled +# Toggle enabled → check handler receives correct element +``` + +**Verify browser updates:** + +For EACH handler that uses `send_data_to_client_hook()`: +1. Check handler uses `get_element_from_callback()` to get full element +2. Check payload sent to browser is complete +3. Verify browser JavaScript receives `data.payload.{element}` with all fields +4. Test in browser: trigger action → verify immediate update without page refresh + +**Handlers sending to browser:** +- [ ] `update_location_on_map` → sends `location_update` +- [ ] `table_row` → sends `table_row` +- [ ] `update_enabled_flag` → sends `element_content` +- [ ] `table_rows` (players) → sends `table_row` +- [ ] `update_widget` (players) → sends `table_row_content` + +All must send complete elements. + +## Step 6: Example for Future + +**New trigger - warn player on low health:** + +```python +def low_health_warning(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + matched_path = kwargs.get("matched_path", "") + + # Get full player element + for steamid, data in updated_values_dict.items(): + player = module.get_element_from_callback( + {steamid: data}, + matched_path + ) + + # Check health + if player.get("health", 100) < 60: + module.trigger_action_hook(module, event_data=[ + 'say_to_player', + {'steamid': steamid, 'message': 'Low health!'} + ]) + +# Register at health property level (depth 5): +widget_meta = { + "handlers": { + "module_players/elements/%map_identifier%/%steamid%/health": low_health_warning + } +} + +# Handler receives full player element, not just health value! +# player = { +# "steamid": "12345", +# "name": "Player", +# "health": 45, ← changed field +# "pos": {...}, +# ... ← all other fields +# } +``` + +**New action - set player flag:** + +```python +def main_function(module, event_data, dispatchers_steamid): + steamid = event_data[1].get("steamid") + flag_value = event_data[1].get("flag_value") + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset") + + # Update element + module.update_element( + "module_players", + active_dataset, + steamid, + None, # player has no sub-identifier + {"custom_flag": flag_value}, + dispatchers_steamid=dispatchers_steamid + ) +``` + +Done. Clear pattern for everything. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e70307c --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the dataset of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf0cb68 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# chrani-bot-tng +Flexible, modern, and easy to extend bot/webinterface for the game 7dtd + +### *Important!* +`Do use the development branch for updates - master is rarely updated and only meant for +stable (not necessarily usable ^^), and testing is the bleeding edge branch that might be broken or full of bugs. +It's usually not though :)` + +### Vision +After running a gameserver for several years, and using several managers and bots, I have realized one thing: They +heavily modify the game-experience. In both ways, good or bad + +While many of their features add elements and experiences to the game, they also take away from the core game itself. +Having teleports to move around, having protected stuff and areas, item shops... + +The aim of this bot is to not alter the games experience by much, but only to add to it. +Specially for Admins/Moderators, and Builders. The casual player may not even notice that a bot is at work, apart +from the authentication process of course ^^ + +### Current state +The bot works on any Vanilla install, and basic functions will work right out of the box. +For more advanced stuff like hiding chat-commands or exporting prefabs, you will need add a few server-side mods. + +There's a fairly [comprehensive installation guide on the projects-wiki](https://github.com/wwevo/chrani-bot-tng/wiki/Installation) + +I've been testing the bot with Vanilla, Alloc's + BCM server-mods and the latest Darkness Falls Stable. + +#### Core Functions +* Module based functionality + * new modules can be added easily and without altering any of the bots files, just drop it in and start using them + +* Trigger-based actions and reactions + * any telnet log output can be parsed for triggers, using regular expressions + * any database access can be monitored for triggers, using set paths + * any user-input from the webinterface... well... yeah of course that is monitored :) + +* Socket/Push based, **LCARS**-Style Interface :) + * Steam-login for authentication + * Widget system to easily extend the webinterface + +#### Modules: +* Telnet-log widget + +* Player-table widget (delete, login-status, info, kick) + * kick players with a custom message + * view basic info like kills, deaths, last time online etc. + +* Location widget (create, edit, delete, records time and place of death) + * a location can be designated as a Lobby to keep people in + * locations can be made screamer-proof, all screamers will be killed on spawn + * locations can be set up as a home, with a dedicated teleport entry + * locations can be set up as a village, there's no attached functionality as of yet + * a place of death location will be updated on every player-death + * locations can be exported and restored if they are of the 'box' type and BCM is installed + +* (Timed) Remote shutdown procedure + * timer is fixed to 30 seconds currently + +* Simple gametime display + * Will show the next Bloodmoon + +* Entity widget + * simply shows all active entities + +* Permissions widget to gate commands + * a password can be used to authenticate a player, for example, to give them the ability to leave the Lobby + \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0b13d3a --- /dev/null +++ b/app.py @@ -0,0 +1,10 @@ +# coding=utf-8 +""" Lasciate ogni speranza voi ch’entrate """ +from bot import setup_modules, start_modules +from time import sleep + +setup_modules() +start_modules() + +while True: + sleep(0.1) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..ed8d17f --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,105 @@ +""" some IDE's will throw 'PEP 8' warnings for imports, but this has to happen early, I think """ +from gevent import monkey +monkey.patch_all() + +""" standard imports """ +from importlib import import_module +from os import path, chdir, walk +import json +from collections import deque +from bot.logger import get_logger + +root_dir = path.dirname(path.abspath(__file__)) +logger = get_logger("init") +chdir(root_dir) + +loaded_modules_dict = {} # this will be populated by the imports done next: +telnet_prefixes = { + "telnet_log": { + # Modern 7D2D servers still include timestamp/stardate/INF in "Executing command" lines + # Format: 2025-11-18T20:20:59 4851.528 INF Executing command... + "timestamp": r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s(?P[-+]?\d*\.\d+|\d+)\sINF\s" + }, + "GMSG": { + "command": ( + r"GMSG:\s" + r"Player\s\'" + r"(?P.*)" + r"\'\s" + r"(?P.*)$" + ) + }, + "BCM": { + "chat": ( + r"Chat\shandled\sby\smod\s\'(?P.*?)\':\s" + r"Chat\s\(from\s\'(?P.*?)\',\sentity\sid\s\'(?P.*?)\',\s" + r"to\s\'(?P.*)\'\)\:\s" + ) + }, + "Allocs": { + "chat": ( + r"Chat\s\(from \'(?P.*)\',\sentity\sid\s\'(?P.*)\',\s" + r"to \'(?P.*)\'\)\:\s" + ) + } +} +modules_to_start_list = deque() +module_loading_order = [] +started_modules_dict = {} + +available_modules_list = next(walk(path.join('modules', '.')))[1] + +for module in available_modules_list: + """ at the bottom of each module, the loaded_modules_list will be updated + modules may not do any stuff in their __init__, apart from setting variables + and calling static methods, unless you know what you are doing """ + import_module("bot.modules." + module) + + +def batch_setup_modules(modules_list): + + def get_options_dict(module_name): + try: + options_dir = "{}/{}".format(root_dir, "options") + with open(path.join(options_dir, module_name + ".json")) as open_file: + return json.load(open_file) + except FileNotFoundError: + return dict + + if len(module_loading_order) >= 1: + for module_to_setup in module_loading_order: + module_options_dict = get_options_dict(module_to_setup) + + loaded_modules_dict[module_to_setup].setup(module_options_dict) + modules_to_start_list.append(loaded_modules_dict[module_to_setup]) + + else: + """ this should load all module in an order they can work with + Make absolutely SURE there's no circular dependencies, because I won't :) """ + for module_to_setup in modules_list: + try: + if isinstance(loaded_modules_dict[module_to_setup].required_modules, list): # has dependencies, load those first! + batch_setup_modules(loaded_modules_dict[module_to_setup].required_modules) + raise AttributeError + except AttributeError: # raised by isinstance = has no dependencies, load right away + if loaded_modules_dict[module_to_setup] not in modules_to_start_list: + module_options_dict = get_options_dict(module_to_setup) + + loaded_modules_dict[module_to_setup].setup(module_options_dict) + modules_to_start_list.append(loaded_modules_dict[module_to_setup]) + + +def setup_modules(): + loaded_modules_identifier_list = [] + for loaded_module_identifier, loaded_module in loaded_modules_dict.items(): + loaded_modules_identifier_list.append(loaded_module.get_module_identifier()) + + batch_setup_modules(loaded_modules_identifier_list) + + +def start_modules(): + for module_to_start in modules_to_start_list: + module_to_start.start() + started_modules_dict[module_to_start.get_module_identifier()] = module_to_start + if len(loaded_modules_dict) == len(started_modules_dict): + logger.info("modules_started", count=len(started_modules_dict), modules=list(started_modules_dict.keys())) diff --git a/bot/constants.py b/bot/constants.py new file mode 100644 index 0000000..eead7c1 --- /dev/null +++ b/bot/constants.py @@ -0,0 +1,98 @@ +""" +Constants for chrani-bot-tng + +This module contains all constants used throughout the bot to avoid +magic numbers and improve maintainability. +""" + +# ============================================================================= +# Permission Levels +# ============================================================================= + +# Permission levels for player access control +PERMISSION_LEVEL_ADMIN = 0 # Full access +PERMISSION_LEVEL_MODERATOR = 1 # Moderate players +PERMISSION_LEVEL_BUILDER = 2 # Build permissions +PERMISSION_LEVEL_PLAYER = 4 # Regular player +PERMISSION_LEVEL_DEFAULT = 2000 # Default for new/unknown players + +# ============================================================================= +# Telnet Command Timeouts (seconds) +# ============================================================================= + +TELNET_TIMEOUT_VERY_SHORT = 1.5 # Quick polls +TELNET_TIMEOUT_SHORT = 2 # Standard commands (lp, gettime) +TELNET_TIMEOUT_NORMAL = 3 # Most commands (listents) +TELNET_TIMEOUT_EXTENDED = 8 # Complex commands (manage entities) +TELNET_TIMEOUT_RECONNECT = 10 # Wait before reconnect attempt + +# ============================================================================= +# Telnet Buffer Limits +# ============================================================================= + +TELNET_BUFFER_MAX_SIZE = 12288 # Maximum telnet buffer size (bytes) +TELNET_LINES_MAX_HISTORY = 150 # Maximum telnet lines to keep in history + +# ============================================================================= +# Server Settings +# ============================================================================= + +DEFAULT_SERVER_PORT = 5000 # Default webserver port +DEFAULT_OBSERVER_INTERVAL = 0.1 # Default module polling interval (seconds) + +# ============================================================================= +# WebSocket Settings +# ============================================================================= + +WEBSOCKET_PING_TIMEOUT = 15 # WebSocket ping timeout (seconds) +WEBSOCKET_PING_INTERVAL = 5 # WebSocket ping interval (seconds) + +# ============================================================================= +# Thread Pool Settings +# ============================================================================= + +CALLBACK_THREAD_POOL_SIZE = 10 # Max concurrent callback threads + +# ============================================================================= +# String/Token Generation +# ============================================================================= + +DEFAULT_TOKEN_LENGTH = 20 # Default length for random tokens + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_permission_level_name(level: int) -> str: + """ + Get human-readable name for a permission level. + + Args: + level: Permission level integer + + Returns: + String name of the permission level + """ + permission_names = { + PERMISSION_LEVEL_ADMIN: "Admin", + PERMISSION_LEVEL_MODERATOR: "Moderator", + PERMISSION_LEVEL_BUILDER: "Builder", + PERMISSION_LEVEL_PLAYER: "Player", + PERMISSION_LEVEL_DEFAULT: "Default/Unknown" + } + return permission_names.get(level, f"Custom ({level})") + + +def is_admin(permission_level: int) -> bool: + """Check if permission level is admin.""" + return permission_level == PERMISSION_LEVEL_ADMIN + + +def is_moderator_or_higher(permission_level: int) -> bool: + """Check if permission level is moderator or higher (lower number = higher privilege).""" + return permission_level <= PERMISSION_LEVEL_MODERATOR + + +def is_builder_or_higher(permission_level: int) -> bool: + """Check if permission level is builder or higher.""" + return permission_level <= PERMISSION_LEVEL_BUILDER diff --git a/bot/git.txt b/bot/git.txt new file mode 100644 index 0000000..62d80f8 --- /dev/null +++ b/bot/git.txt @@ -0,0 +1,31 @@ +IntelliJ Git Workflow für Claude-Branches +Teil 1: Branch auschecken und Updates pullen + +Erstmaliges Auschecken eines neuen Claude-Branches: + + Git → Fetch (holt alle Remote-Branches) + Git → Branches... (oder rechts unten auf den aktuellen Branch-Namen klicken) + In der Branch-Liste unter "Remote Branches" → "origin" den Branch claude/add-map-legend-... finden + Rechtsklick auf den Branch → "Checkout" + IntelliJ fragt "Checkout as new local branch?" → "Checkout" klicken + +Updates vom gleichen Branch pullen (nach meinen Änderungen): + + Git → Pull... (Strg+T) + Im Dialog ist bereits der richtige Branch vorausgewählt + Einfach "Pull" klicken + +Teil 2: Branch in Master integrieren und pushen + +Wenn alle Tests erfolgreich sind und der Branch fertig ist: + + Git → Branches... + "master" auswählen → "Checkout" (du wechselst auf master) + Git → Merge... + Den claude/add-map-legend-... Branch auswählen → "Merge" klicken + Git → Push... (Strg+Shift+K) + "Push" klicken + +Fertig. Der Code ist jetzt in master und auf dem Remote-Server. + +Das war's - kein Terminal, keine Alternativen, nur diese Schritte. diff --git a/bot/logger.py b/bot/logger.py new file mode 100644 index 0000000..4b9df6c --- /dev/null +++ b/bot/logger.py @@ -0,0 +1,311 @@ +""" +Structured logging system for chrani-bot-tng + +Provides context-aware logging with consistent formatting across the entire application. +Replaces print() statements with structured, grep-able log output. + +Usage: + from bot.logger import get_logger + + logger = get_logger("webserver") + + # Error logging (always shown) + logger.error("tile_fetch_failed", user="steamid123", z=4, x=-2, y=1, status=404) + + # Warning logging (always shown) + logger.warn("auth_missing_sid", user="steamid456", action="tile_request") + + # Info logging (startup only, can be disabled) + logger.info("module_loaded", module="webserver", version="1.0") + + # Debug logging (opt-in via config) + logger.debug("action_trace", action="select_dom_element", path="/map/owner/id") + +Output format: + [ERROR] [2025-01-19 12:34:56.123] tile_fetch_failed | user=steamid123 z=4 x=-2 y=1 status=404 +""" + +import sys +from datetime import datetime +from typing import Any, Dict, Optional + + +class Colors: + """ANSI color codes for terminal output""" + # Foreground colors (bright versions for better visibility) + RED = "\033[91m" # Errors + YELLOW = "\033[93m" # Warnings + GREEN = "\033[92m" # Info + GRAY = "\033[90m" # Debug + LIGHT_GRAY = "\033[37m" # Timestamps + WHITE = "\033[97m" # Event/context text + RESET = "\033[0m" # Reset to default + + # Optional: Bold variants + BOLD = "\033[1m" + + +class LogLevel: + """Log level constants""" + ERROR = "ERROR" + WARN = "WARN" + INFO = "INFO" + DEBUG = "DEBUG" + + +class LogConfig: + """Global logging configuration""" + # Which levels to actually output + enabled_levels = { + LogLevel.ERROR, # Always enabled + LogLevel.WARN, # Always enabled + LogLevel.INFO, # Enabled (for now, can be disabled after testing) + # LogLevel.DEBUG # Disabled by default, uncomment to enable + } + + # Format settings + show_timestamps = True + timestamp_format = "%Y-%m-%d %H:%M:%S.%f" # Includes microseconds + use_colors = True # Enable colored output + + @classmethod + def enable_debug(cls): + """Enable debug logging (call this from config or command line)""" + cls.enabled_levels.add(LogLevel.DEBUG) + + @classmethod + def disable_info(cls): + """Disable info logging (for production)""" + cls.enabled_levels.discard(LogLevel.INFO) + + @classmethod + def disable_colors(cls): + """Disable colored output (for log files or incompatible terminals)""" + cls.use_colors = False + + +class ContextLogger: + """ + Structured logger with context support + + Provides consistent, grep-able logging across the application. + Each logger instance is bound to a specific module/component. + """ + + def __init__(self, module_name: str): + """ + Create a logger for a specific module + + Args: + module_name: Name of the module (e.g., "webserver", "dom_management") + """ + self.module_name = module_name + + def _format_context(self, **context) -> str: + """ + Format context dict as key=value pairs + + Args: + **context: Arbitrary key-value pairs + + Returns: + Formatted string: "key1=value1 key2=value2" + """ + if not context: + return "" + + # Sort keys for consistent output + pairs = [] + for key in sorted(context.keys()): + value = context[key] + + # Format value appropriately + if value is None: + formatted_value = "null" + elif isinstance(value, str): + # Escape spaces and special chars if needed + if " " in value or "=" in value: + formatted_value = f'"{value}"' + else: + formatted_value = value + elif isinstance(value, bool): + formatted_value = str(value).lower() + elif isinstance(value, (list, dict)): + # Complex types: just show type and length + formatted_value = f"{type(value).__name__}[{len(value)}]" + else: + formatted_value = str(value) + + pairs.append(f"{key}={formatted_value}") + + return " ".join(pairs) + + def _log(self, level: str, event: str, **context): + """ + Internal logging method + + Args: + level: Log level (ERROR, WARN, INFO, DEBUG) + event: Event name (snake_case, descriptive) + **context: Additional context as key=value pairs + """ + # Check if this level is enabled + if level not in LogConfig.enabled_levels: + return + + # Always include module in context + context["module"] = self.module_name + + # Build timestamp + timestamp = "" + timestamp_color = "" + if LogConfig.show_timestamps: + now = datetime.now() + # Truncate microseconds to milliseconds + timestamp_str = now.strftime(LogConfig.timestamp_format)[:-3] + if LogConfig.use_colors: + timestamp_color = Colors.LIGHT_GRAY + timestamp = f"{timestamp_color}[{timestamp_str}]{Colors.RESET} " + else: + timestamp = f"[{timestamp_str}] " + + # Build context string + context_str = self._format_context(**context) + + # Get color for this level + level_color = "" + text_color = "" + reset = "" + if LogConfig.use_colors: + if level == LogLevel.ERROR: + level_color = Colors.RED + elif level == LogLevel.WARN: + level_color = Colors.YELLOW + elif level == LogLevel.INFO: + level_color = Colors.GREEN + elif level == LogLevel.DEBUG: + level_color = Colors.GRAY + text_color = Colors.WHITE + reset = Colors.RESET + + # Format: [LEVEL] [TIMESTAMP] event | context + # Only the [LEVEL] tag is colored, rest is white text with gray timestamp + if context_str: + message = f"{level_color}[{level:<5}]{reset} {timestamp}{text_color}{event} | {context_str}{reset}" + else: + message = f"{level_color}[{level:<5}]{reset} {timestamp}{text_color}{event}{reset}" + + # Output to stderr (standard for logs, keeps stdout clean) + print(message, file=sys.stderr, flush=True) + + def error(self, event: str, **context): + """ + Log an error - always shown, indicates something failed + + Use for: + - Action failures + - Network errors + - Parse errors + - Validation failures + - Unexpected exceptions + + Args: + event: Error event name (e.g., "tile_fetch_failed") + **context: Additional context (user, action, error message, etc.) + """ + self._log(LogLevel.ERROR, event, **context) + + def warn(self, event: str, **context): + """ + Log a warning - always shown, indicates something unexpected but handled + + Use for: + - Fallback behavior activated + - Deprecated feature used + - Rate limiting triggered + - Auth retry needed + - Missing optional config + + Args: + event: Warning event name (e.g., "auth_missing_sid") + **context: Additional context + """ + self._log(LogLevel.WARN, event, **context) + + def info(self, event: str, **context): + """ + Log informational message - shown during startup, can be disabled + + Use for: + - Module loaded + - Server started + - Configuration summary + - Connection established + + Args: + event: Info event name (e.g., "module_loaded") + **context: Additional context + """ + self._log(LogLevel.INFO, event, **context) + + def debug(self, event: str, **context): + """ + Log debug information - disabled by default, opt-in + + Use for: + - Action execution traces + - Data transformations + - Flow control decisions + - Detailed state changes + + Enable with: LogConfig.enable_debug() + + Args: + event: Debug event name (e.g., "action_trace") + **context: Additional context + """ + self._log(LogLevel.DEBUG, event, **context) + + +# Global logger registry +_loggers: Dict[str, ContextLogger] = {} + + +def get_logger(module_name: str) -> ContextLogger: + """ + Get or create a logger for a specific module + + Args: + module_name: Module identifier (e.g., "webserver", "dom_management") + + Returns: + ContextLogger instance for this module + + Example: + logger = get_logger("webserver") + logger.error("connection_failed", host="localhost", port=8080) + """ + if module_name not in _loggers: + _loggers[module_name] = ContextLogger(module_name) + return _loggers[module_name] + + +# Convenience function for quick usage +def log_error(module: str, event: str, **context): + """Quick error logging without getting logger instance""" + get_logger(module).error(event, **context) + + +def log_warn(module: str, event: str, **context): + """Quick warning logging without getting logger instance""" + get_logger(module).warn(event, **context) + + +def log_info(module: str, event: str, **context): + """Quick info logging without getting logger instance""" + get_logger(module).info(event, **context) + + +def log_debug(module: str, event: str, **context): + """Quick debug logging without getting logger instance""" + get_logger(module).debug(event, **context) diff --git a/bot/mixins/__init__.py b/bot/mixins/__init__.py new file mode 100644 index 0000000..97e1f2c --- /dev/null +++ b/bot/mixins/__init__.py @@ -0,0 +1 @@ +__all__ = ["action", "template", "trigger", "widget"] diff --git a/bot/mixins/action.py b/bot/mixins/action.py new file mode 100644 index 0000000..2eb2df7 --- /dev/null +++ b/bot/mixins/action.py @@ -0,0 +1,106 @@ +from os import path, listdir, pardir +from importlib import import_module +from threading import Thread +from bot import loaded_modules_dict +import string +import random + + +class Action(object): + available_actions_dict = dict + trigger_action_hook = object + + def __init__(self): + self.available_actions_dict = {} + self.trigger_action_hook = self.trigger_action + + def register_action(self, identifier, action_dict): + self.available_actions_dict[identifier] = action_dict + + def enable_action(self, identifier): + self.available_actions_dict[identifier]["enabled"] = True + + def disable_action(self, identifier): + self.available_actions_dict[identifier]["enabled"] = False + + @staticmethod + def id_generator(size=6, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for _ in range(size)) + + @staticmethod + def get_all_available_actions_dict(): + all_available_actions_dict = {} + for loaded_module_identifier, loaded_module in loaded_modules_dict.items(): + if len(loaded_module.available_actions_dict) >= 1: + all_available_actions_dict[loaded_module_identifier] = loaded_module.available_actions_dict + + return all_available_actions_dict + + def import_actions(self): + modules_root_dir = path.join(path.dirname(path.abspath(__file__)), pardir, "modules") + + module_actions_root_dir = path.join(modules_root_dir, self.options['module_name'], "actions") + try: + for module_action in listdir(module_actions_root_dir): + if module_action == 'common.py' or module_action == '__init__.py' or module_action[-3:] != '.py': + continue + import_module("bot.modules." + self.options['module_name'] + ".actions." + module_action[:-3]) + except FileNotFoundError as error: + # module does not have actions + pass + + @staticmethod + def trigger_action(target_module, event_data=None, dispatchers_steamid=None): + if event_data is None: + event_data = [] + action_identifier = event_data[0] + if action_identifier in target_module.available_actions_dict: + server_is_online = target_module.dom.data.get("module_telnet", {}).get("server_is_online", False) + active_action = target_module.available_actions_dict[action_identifier] + action_requires_server_to_be_online = active_action.get( + "requires_telnet_connection", False + ) + action_is_enabled = active_action.get("enabled", False) + user_has_permission = event_data[1].get("has_permission", None) + # permission is None = no status has been set, so it's allowed (default) + # permission is True = Permission has been set by some other process + # permission is False = permission has not been granted by any module + + if dispatchers_steamid is not None: + # none would be a system-call + pass + + if action_is_enabled: + event_data[1]["module"] = target_module.getName() + event_data[1]["uuid4"] = target_module.id_generator(22) + if server_is_online is True or action_requires_server_to_be_online is not True: + if any([ + user_has_permission is None, + user_has_permission is True + ]): + Thread( + target=active_action.get("main_function"), + args=(target_module, event_data, dispatchers_steamid) + ).start() + else: + # in case we don't have permission, we call the fail callback. it then can determine what to do + # next + fail_callback = active_action.get("callback_fail") + Thread( + target=target_module.callback_fail( + fail_callback, + target_module, + event_data, + dispatchers_steamid + ), + args=(target_module, event_data, dispatchers_steamid) + ).start() + else: + try: + skip_it_callback = active_action.get("skip_it") + Thread( + target=skip_it_callback, + args=(target_module, event_data) + ).start() + except KeyError: + pass diff --git a/bot/mixins/template.py b/bot/mixins/template.py new file mode 100644 index 0000000..099de6a --- /dev/null +++ b/bot/mixins/template.py @@ -0,0 +1,15 @@ +from os import path, pardir +import jinja2 + + +class Template(object): + templates = object + + def __init__(self): + pass + + def import_templates(self): + modules_root_dir = path.join(path.dirname(path.abspath(__file__)), pardir, "modules") + modules_template_dir = path.join(modules_root_dir, self.options['module_name'], 'templates') + file_loader = jinja2.FileSystemLoader(modules_template_dir) + self.templates = jinja2.Environment(loader=file_loader) diff --git a/bot/mixins/trigger.py b/bot/mixins/trigger.py new file mode 100644 index 0000000..aec51a4 --- /dev/null +++ b/bot/mixins/trigger.py @@ -0,0 +1,62 @@ +from bot import loaded_modules_dict +from os import path, listdir, pardir +from importlib import import_module +import re + + +class Trigger(object): + available_triggers_dict = dict + + def __init__(self): + self.available_triggers_dict = {} + + def start(self): + try: + for name, triggers in self.available_triggers_dict.items(): + try: + for trigger, handler in triggers["handlers"].items(): + self.dom.data.register_callback(self, trigger, handler) + except KeyError: + pass + except KeyError as error: + pass + + def register_trigger(self, identifier, trigger_dict): + self.available_triggers_dict[identifier] = trigger_dict + + def import_triggers(self): + modules_root_dir = path.join(path.dirname(path.abspath(__file__)), pardir, "modules") + + module_triggers_root_dir = path.join(modules_root_dir, self.options['module_name']) + try: + for module_trigger in listdir(path.join(module_triggers_root_dir, "triggers")): + if module_trigger == 'common.py' or module_trigger == '__init__.py' or module_trigger[-3:] != '.py': + continue + import_module("bot.modules." + self.options['module_name'] + ".triggers." + module_trigger[:-3]) + + for module_trigger in listdir(path.join(module_triggers_root_dir, "commands")): + if module_trigger == 'common.py' or module_trigger == '__init__.py' or module_trigger[-3:] != '.py': + continue + import_module("bot.modules." + self.options['module_name'] + ".commands." + module_trigger[:-3]) + + except FileNotFoundError: + pass + + except ModuleNotFoundError: + pass + + def execute_telnet_triggers(self): + telnet_lines_to_process = self.telnet.get_a_bunch_of_lines_from_queue(25) + + for telnet_line in telnet_lines_to_process: + for loaded_module in loaded_modules_dict.values(): + for trigger_name, trigger_group in loaded_module.available_triggers_dict.items(): + try: + for trigger in trigger_group["triggers"]: + regex_results = re.search(trigger["regex"], telnet_line) + if regex_results: + trigger["callback"](loaded_module, self, regex_results) + # TODO: add method to append log, or create a new one + # TODO: this needs to weed out triggers being called too often + except KeyError: + pass diff --git a/bot/mixins/widget.py b/bot/mixins/widget.py new file mode 100644 index 0000000..56f188c --- /dev/null +++ b/bot/mixins/widget.py @@ -0,0 +1,87 @@ +from os import path, listdir, pardir +from importlib import import_module +from bot import loaded_modules_dict + + +class Widget(object): + available_widgets_dict = dict + template_render_hook = object + + def __init__(self): + self.available_widgets_dict = {} + self.template_render_hook = self.template_render + + def on_socket_connect(self, steamid): + if isinstance(self.available_widgets_dict, dict) and len(self.available_widgets_dict) >= 1: + for name, widget in self.available_widgets_dict.items(): + if widget["main_widget"] is not None: + widget["main_widget"](self, dispatchers_steamid=steamid) + + def on_socket_disconnect(self, steamid): + if isinstance(self.available_widgets_dict, dict) and len(self.available_widgets_dict) >= 1: + for name, widget in self.available_widgets_dict.items(): + if widget["main_widget"] is not None: + widget["main_widget"](self, dispatchers_steamid=steamid) + + def on_socket_event(self, event_data, dispatchers_steamid): + pass + + @staticmethod + def template_render(*args, **kwargs): + try: + template = kwargs.get("template", None) + rendered_template = template.render(**kwargs) + except AttributeError as error: + rendered_template = "" + + return rendered_template + + @staticmethod + def get_all_available_widgets_dict(): + all_available_widgets_dict = {} + for loaded_module_identifier, loaded_module in loaded_modules_dict.items(): + if len(loaded_module.available_widgets_dict) >= 1: + all_available_widgets_dict[loaded_module_identifier] = loaded_module.available_widgets_dict + + return all_available_widgets_dict + + def start(self): + if isinstance(self.available_widgets_dict, dict) and len(self.available_widgets_dict) >= 1: + for name, widget in self.available_widgets_dict.items(): + for trigger, handler in widget["handlers"].items(): + self.dom.data.register_callback(self, trigger, handler) + + def register_widget(self, identifier, widget_dict): + if widget_dict.get("enabled", True): + self.available_widgets_dict[identifier] = widget_dict + + def import_widgets(self): + modules_root_dir = path.join(path.dirname(path.abspath(__file__)), pardir, "modules") + + module_widgets_root_dir = path.join(modules_root_dir, self.options['module_name'], "widgets") + try: + for module_widget in listdir(module_widgets_root_dir): + if module_widget == 'common.py' or module_widget == '__init__.py' or module_widget[-3:] != '.py': + continue + import_module("bot.modules." + self.options['module_name'] + ".widgets." + module_widget[:-3]) + except FileNotFoundError as error: + # module does not have widgets + pass + + def get_current_view(self, dispatchers_steamid): + return ( + self.dom.data + .get(self.get_module_identifier(), {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("current_view", "frontend") + ) + + def set_current_view(self, dispatchers_steamid, options): + self.dom.data.upsert({ + self.get_module_identifier(): { + "visibility": { + dispatchers_steamid: options + } + } + }, dispatchers_steamid=dispatchers_steamid) diff --git a/bot/module.py b/bot/module.py new file mode 100644 index 0000000..5cbce93 --- /dev/null +++ b/bot/module.py @@ -0,0 +1,103 @@ +from threading import Thread, Event +from bot import started_modules_dict +from bot.logger import get_logger +from bot.mixins.trigger import Trigger +from bot.mixins.action import Action +from bot.mixins.template import Template +from bot.mixins.widget import Widget + +logger = get_logger("module") + + +class Module(Thread, Action, Trigger, Template, Widget): + """ This class may ONLY be used to extend a module, it is not meant to be instantiated on it's own """ + options = dict + stopped = object + + run_observer_interval = int + run_observer_interval_idle = int + + last_execution_time = float + + def __init__(self): + if type(self) is Module: + raise NotImplementedError("You may not instantiate this class on it's own") + + self.stopped = Event() + Action.__init__(self) + Trigger.__init__(self) + Template.__init__(self) + Widget.__init__(self) + Thread.__init__(self) + + def setup(self, provided_options=dict): + self.options = self.default_options + options_filename = "module_" + self.options['module_name'] + ".json" + if isinstance(provided_options, dict): + self.options.update(provided_options) + logger.debug("module_options_loaded", + module=self.options['module_name'], + options_file=options_filename) + else: + logger.debug("module_options_defaults", + module=self.default_options["module_name"], + options_file=options_filename) + + self.import_triggers() + self.import_actions() + self.import_templates() + self.import_widgets() + self.name = self.options['module_name'] + return self + + def start(self): + for required_module in self.required_modules: + setattr(self, required_module[7:], started_modules_dict[required_module]) + setattr(self, self.name, self) # add self to dynamic module list to unify calls from actions + + self.setDaemon(daemonic=True) + Thread.start(self) + Widget.start(self) + Trigger.start(self) + + return self + + def on_socket_connect(self, dispatchers_steamid): + Widget.on_socket_connect(self, dispatchers_steamid) + + def on_socket_disconnect(self, dispatchers_steamid): + Widget.on_socket_disconnect(self, dispatchers_steamid) + + def on_socket_event(self, event_data, dispatchers_steamid): + self.trigger_action_hook(self, event_data=event_data, dispatchers_steamid=dispatchers_steamid) + self.emit_event_status(self, event_data, dispatchers_steamid) + + Widget.on_socket_event(self, event_data, dispatchers_steamid) + + def emit_event_status(self, module, event_data, recipient_steamid, status=None): + # recipient_steamid can be None, "all" or [list_of_steamid's] + if recipient_steamid is not None and status is not None: + recipient_steamid = [recipient_steamid] + + self.webserver.emit_event_status(module, event_data, recipient_steamid, status) + + @staticmethod + def callback_success(callback, module, event_data, dispatchers_steamid, match=None): + event_data[1]["status"] = "success" + action_identifier = event_data[1]["action_identifier"] + if event_data[1].get("disable_after_success"): + module.disable_action(action_identifier) + + module.emit_event_status(module, event_data, dispatchers_steamid, event_data[1]) + callback(module, event_data, dispatchers_steamid, match) + + @staticmethod + def callback_fail(callback, module, event_data, dispatchers_steamid): + event_data[1]["status"] = "fail" + logger.error("action_failed", + module=module.getName(), + action=event_data[0], + reason=event_data[1].get("fail_reason", "unknown"), + user=dispatchers_steamid) + module.emit_event_status(module, event_data, dispatchers_steamid, event_data[1]) + callback(module, event_data, dispatchers_steamid) diff --git a/bot/modules/__init__.py b/bot/modules/__init__.py new file mode 100644 index 0000000..8ad029a --- /dev/null +++ b/bot/modules/__init__.py @@ -0,0 +1,11 @@ +__all__ = [ + 'dom', + 'webserver', + 'dom_management', + 'telnet', + 'game_environment', + 'players', + 'locations', + 'permissions', + 'storage' +] diff --git a/bot/modules/dom/__init__.py b/bot/modules/dom/__init__.py new file mode 100644 index 0000000..0bb9e2a --- /dev/null +++ b/bot/modules/dom/__init__.py @@ -0,0 +1,73 @@ +import json +from bot.module import Module +from bot import loaded_modules_dict +from .callback_dict import CallbackDict + + +class Dom(Module): + data = CallbackDict + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:] + }) + + setattr(self, "required_modules", []) + + self.data = CallbackDict() + self.run_observer_interval = 5 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_dom" + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + # endregion + + def get_updated_or_default_value(self, module_identifier, identifier, updated_values_dict, default_value): + try: + updated_or_default_value = updated_values_dict.get( + identifier, self.data.get(module_identifier).get(identifier, default_value) + ) + except AttributeError as error: + updated_or_default_value = default_value + + return updated_or_default_value + + """ method to retrieve any dom elements based on their name or key """ + def get_dom_element_by_query( + self, + dictionary=None, + target_module="module_dom", + query="", + current_layer=0, + path=None + ): + starting_layer = len(loaded_modules_dict[target_module].dom_element_root) + if path is None: + path = [] + if dictionary is None: + dictionary = self.data.get(target_module, {}).get("elements", {}) + + for key, value in dictionary.items(): + if type(value) is dict: + yield from self.get_dom_element_by_query( + dictionary=value, + target_module=target_module, + query=query, + current_layer=current_layer + 1, + path=path + [key] + ) + else: + if current_layer >= starting_layer and key == query: + yield path, key, value + + @staticmethod + def pretty_print_dict(dict_to_print=dict): + print(json.dumps(dict_to_print, sort_keys=True, indent=4)) + + +loaded_modules_dict[Dom().get_module_identifier()] = Dom() diff --git a/bot/modules/dom/callback_dict.py b/bot/modules/dom/callback_dict.py new file mode 100644 index 0000000..666d261 --- /dev/null +++ b/bot/modules/dom/callback_dict.py @@ -0,0 +1,514 @@ +""" +Reactive Dictionary with Callbacks + +A dictionary implementation that triggers callbacks when values are modified. +Similar to React's state management but for Python dictionaries. + +Features: +- Monitors nested dictionary changes (insert, update, delete, append) +- Path-based callback registration with wildcard support +- Thread-safe callback execution +- Efficient path matching using pre-compiled patterns +""" + +from typing import Dict, List, Any, Optional, Callable, Tuple +from collections.abc import Mapping +from collections import deque +from threading import Thread +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from functools import reduce +import operator +import re + +from bot import loaded_modules_dict +from bot.constants import CALLBACK_THREAD_POOL_SIZE +from bot.logger import get_logger + +logger = get_logger("telnet") +class CallbackDict(dict): + """ + A dictionary that triggers registered callbacks when its values change. + + Callbacks can be registered for specific paths (e.g., "players/76561198012345678/name") + or with wildcards (e.g., "players/%steamid%/name"). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Structure: {depth: {path_pattern: [callback_info, ...]}} + self._callbacks: Dict[int, Dict[str, List[Dict]]] = {} + # Compiled regex patterns for fast matching: {path_pattern: compiled_regex} + self._compiled_patterns: Dict[str, re.Pattern] = {} + # Thread pool for callback execution (reuse threads instead of creating new ones) + self._executor = ThreadPoolExecutor(max_workers=CALLBACK_THREAD_POOL_SIZE, thread_name_prefix="callback") + + # ==================== Path Utilities ==================== + + @staticmethod + def _split_path(path: str) -> List[str]: + """Split a path string into components.""" + return path.split("/") if path else [] + + @staticmethod + def _join_path(components: List[str]) -> str: + """Join path components into a string.""" + return "/".join(components) + + @staticmethod + def _get_nested_value(data: dict, keys: List[str]) -> Any: + """Get a value from a nested dictionary using a list of keys.""" + if not keys: + return data + return reduce(operator.getitem, keys, data) + + def _calculate_depth(self, d: Any) -> int: + """Calculate the maximum depth of a nested dictionary.""" + if not isinstance(d, dict) or not d: + return 0 + return 1 + max((self._calculate_depth(v) for v in d.values()), default=0) + + # ==================== Pattern Matching ==================== + + def _compile_pattern(self, path_pattern: str) -> re.Pattern: + """ + Compile a path pattern into a regex for efficient matching. + + Wildcards like %steamid% become regex capture groups. + Example: "players/%steamid%/name" -> r"^players/([^/]+)/name$" + """ + if path_pattern in self._compiled_patterns: + return self._compiled_patterns[path_pattern] + + # Escape special regex characters except our wildcard markers + escaped = re.escape(path_pattern) + # Convert %wildcard% to regex capture group + regex_pattern = re.sub(r'%[^%]+%', r'([^/]+)', escaped) + # Anchor the pattern + regex_pattern = f"^{regex_pattern}$" + + compiled = re.compile(regex_pattern) + self._compiled_patterns[path_pattern] = compiled + return compiled + + def _match_path(self, path: str, depth: int) -> List[str]: + """ + Find all registered callback patterns that match the given path. + + Returns list of matching pattern strings. + """ + if depth not in self._callbacks: + return [] + + matching_patterns = [] + for pattern in self._callbacks[depth].keys(): + compiled_pattern = self._compile_pattern(pattern) + if compiled_pattern.match(path): + matching_patterns.append(pattern) + + return matching_patterns + + # ==================== Callback Management ==================== + + def register_callback( + self, + module: Any, + path_pattern: str, + callback: Callable + ) -> None: + """ + Register a callback for a specific path pattern. + + Args: + module: The module that owns this callback + path_pattern: Path to monitor (e.g., "players/%steamid%/name") + callback: Function to call when path changes + """ + depth = path_pattern.count('/') + + # Initialize depth level if needed + if depth not in self._callbacks: + self._callbacks[depth] = {} + + # Initialize pattern list if needed + if path_pattern not in self._callbacks[depth]: + self._callbacks[depth][path_pattern] = [] + + # Add callback info + self._callbacks[depth][path_pattern].append({ + "callback": callback, + "module": module + }) + + def _collect_callbacks( + self, + path: str, + method: str, + updated_values: Any, + original_values: Any, + dispatchers_steamid: Optional[str], + min_depth: int = 0, + max_depth: Optional[int] = None + ) -> List[Dict]: + """ + Collect all callbacks that should be triggered for a path change. + + Returns list of callback packages ready for execution. + """ + depth = path.count('/') + + # Check depth constraints + if max_depth is not None and depth > max_depth: + return [] + if depth < min_depth: + return [] + + # Find matching patterns + matching_patterns = self._match_path(path, depth) + if not matching_patterns: + return [] + + # Build callback packages + packages = [] + for pattern in matching_patterns: + for callback_info in self._callbacks[depth][pattern]: + packages.append({ + "target": callback_info["callback"], + "args": (callback_info["module"],), + "kwargs": { + "updated_values_dict": updated_values, + "original_values_dict": original_values, + "dispatchers_steamid": dispatchers_steamid, + "method": method, + "matched_path": pattern + } + }) + + return packages + + def _execute_callbacks(self, callback_packages: List[Dict]) -> None: + """Execute a list of callback packages in separate threads.""" + for package in callback_packages: + self._executor.submit( + self._safe_callback_wrapper, + package + ) + + def _safe_callback_wrapper(self, package: Dict) -> None: + """ + Wrapper that safely executes a callback with error handling. + + This prevents callback errors from breaking the system and provides + visibility into what's failing. + """ + try: + package["target"]( + *package["args"], + **package["kwargs"] + ) + except Exception as error: + # Extract useful context for debugging + module = package["args"][0] if package["args"] else None + module_name = module.getName() if hasattr(module, 'getName') else 'unknown' + matched_path = package["kwargs"].get("matched_path", "unknown") + + logger.error( + "callback_execution_failed", + module=module_name, + path=matched_path, + error=str(error), + error_type=type(error).__name__ + ) + + # ==================== Dictionary Operations ==================== + + def upsert( + self, + updates: Dict, + dispatchers_steamid: Optional[str] = None, + min_callback_level: int = 0, + max_callback_level: Optional[int] = None + ) -> None: + """ + Update or insert values into the dictionary. + + This is the main method for modifying the dictionary. It handles nested + updates intelligently and triggers appropriate callbacks. + + Args: + updates: Dictionary of values to upsert + dispatchers_steamid: ID of the user who triggered this change + min_callback_level: Minimum depth level for callbacks + max_callback_level: Maximum depth level for callbacks + """ + if not isinstance(updates, Mapping) or not updates: + return + + # Make a snapshot of current state before any changes + original_state = deepcopy(dict(self)) + + # Determine max depth if not specified + if max_callback_level is None: + max_callback_level = self._calculate_depth(updates) + + # Collect all callbacks that will be triggered + all_callbacks = [] + + # Process updates recursively + self._upsert_recursive( + current_dict=self, + updates=updates, + original_state=original_state, + path_components=[], + callbacks_accumulator=all_callbacks, + dispatchers_steamid=dispatchers_steamid, + min_depth=min_callback_level, + max_depth=max_callback_level + ) + + # Execute all collected callbacks + self._execute_callbacks(all_callbacks) + + def _upsert_recursive( + self, + current_dict: dict, + updates: Dict, + original_state: Dict, + path_components: List[str], + callbacks_accumulator: List[Dict], + dispatchers_steamid: Optional[str], + min_depth: int, + max_depth: int + ) -> None: + """Recursive helper for upsert operation.""" + current_depth = len(path_components) + + for key, new_value in updates.items(): + # Build the full path for this key + full_path_components = path_components + [key] + full_path = self._join_path(full_path_components) + + # Determine the operation type + key_exists = key in current_dict + old_value = current_dict.get(key) + + if key_exists: + # Update case + if isinstance(old_value, dict) and isinstance(new_value, dict): + # Both are dicts - recurse deeper + method = "update" + self._upsert_recursive( + current_dict=current_dict[key], + updates=new_value, + original_state=original_state.get(key, {}), + path_components=full_path_components, + callbacks_accumulator=callbacks_accumulator, + dispatchers_steamid=dispatchers_steamid, + min_depth=min_depth, + max_depth=max_depth + ) + elif old_value == new_value: + # Value unchanged - skip callbacks + method = "unchanged" + else: + # Value changed - update it + method = "update" + current_dict[key] = new_value + else: + # Insert case + method = "insert" + current_dict[key] = new_value + + # If inserted value is a dict, recurse through it + if isinstance(new_value, dict): + self._upsert_recursive( + current_dict=current_dict[key], + updates=new_value, + original_state={}, + path_components=full_path_components, + callbacks_accumulator=callbacks_accumulator, + dispatchers_steamid=dispatchers_steamid, + min_depth=min_depth, + max_depth=max_depth + ) + + # Collect callbacks for this change (skip if unchanged) + if method != "unchanged": + callbacks = self._collect_callbacks( + path=full_path, + method=method, + updated_values=updates, + original_values=original_state, + dispatchers_steamid=dispatchers_steamid, + min_depth=min_depth, + max_depth=max_depth + ) + callbacks_accumulator.extend(callbacks) + + def append( + self, + updates: Dict, + dispatchers_steamid: Optional[str] = None, + maxlen: Optional[int] = None, + min_callback_level: int = 0, + max_callback_level: Optional[int] = None + ) -> None: + """ + Append values to list entries in the dictionary. + + If the target key doesn't exist, creates a new list. + If maxlen is specified, creates a deque with that maxlen. + + Args: + updates: Dictionary mapping paths to values to append + dispatchers_steamid: ID of user who triggered this + maxlen: Maximum length for created lists (uses deque) + min_callback_level: Minimum depth for callbacks + max_callback_level: Maximum depth for callbacks + """ + if not isinstance(updates, Mapping) or not updates: + return + + original_state = deepcopy(dict(self)) + + if max_callback_level is None: + max_callback_level = self._calculate_depth(updates) + + all_callbacks = [] + + self._append_recursive( + current_dict=self, + updates=updates, + original_state=original_state, + path_components=[], + callbacks_accumulator=all_callbacks, + dispatchers_steamid=dispatchers_steamid, + maxlen=maxlen, + min_depth=min_callback_level, + max_depth=max_callback_level + ) + + self._execute_callbacks(all_callbacks) + + def _append_recursive( + self, + current_dict: dict, + updates: Dict, + original_state: Dict, + path_components: List[str], + callbacks_accumulator: List[Dict], + dispatchers_steamid: Optional[str], + maxlen: Optional[int], + min_depth: int, + max_depth: int + ) -> None: + """Recursive helper for append operation.""" + current_depth = len(path_components) + + for key, value in updates.items(): + full_path_components = path_components + [key] + full_path = self._join_path(full_path_components) + + # Collect callbacks before making changes + callbacks = self._collect_callbacks( + path=full_path, + method="append", + updated_values=updates, + original_values=original_state, + dispatchers_steamid=dispatchers_steamid, + min_depth=min_depth, + max_depth=max_depth + ) + callbacks_accumulator.extend(callbacks) + + # Perform the append operation + if key in current_dict: + try: + current_dict[key].append(value) + except AttributeError: + # Not a list/deque, can't append + pass + else: + # Create new list or deque + if maxlen is not None: + current_dict[key] = deque(maxlen=maxlen) + else: + current_dict[key] = [] + current_dict[key].append(value) + + # If we found callbacks, don't recurse deeper (callback is at this level) + if callbacks: + return + + # Otherwise, recurse if both are dicts + old_value = current_dict.get(key) + if isinstance(value, Mapping) and isinstance(old_value, Mapping): + self._append_recursive( + current_dict=old_value, + updates=value, + original_state=original_state.get(key, {}), + path_components=full_path_components, + callbacks_accumulator=callbacks_accumulator, + dispatchers_steamid=dispatchers_steamid, + maxlen=maxlen, + min_depth=min_depth, + max_depth=max_depth + ) + + def remove_key_by_path( + self, + key_path: List[str], + dispatchers_steamid: Optional[str] = None + ) -> None: + """ + Remove a key from the dictionary by its path. + + Args: + key_path: List of keys representing the path (e.g., ['players', '12345', 'name']) + dispatchers_steamid: ID of user who triggered this + """ + if not key_path: + return + + # Get module's delete root to determine how much of the path to use + try: + module = loaded_modules_dict[key_path[0]] + delete_root = getattr(module, 'dom_element_select_root', []) + keys_to_ignore = len(delete_root) - 1 if delete_root else 0 + except (KeyError, AttributeError): + keys_to_ignore = 0 + + # Build the path for callback matching + if keys_to_ignore >= 1: + path_for_callbacks = self._join_path(key_path[:-keys_to_ignore]) + else: + path_for_callbacks = self._join_path(key_path) + + # Collect callbacks + callbacks = self._collect_callbacks( + path=path_for_callbacks, + method="remove", + updated_values=key_path, + original_values={}, + dispatchers_steamid=dispatchers_steamid, + min_depth=len(key_path), + max_depth=len(key_path) + ) + + # Perform the deletion + try: + parent = self._get_nested_value(self, key_path[:-1]) + del parent[key_path[-1]] + except (KeyError, TypeError, IndexError): + # Key doesn't exist or path is invalid + pass + + # Execute callbacks + self._execute_callbacks(callbacks) + + def __del__(self): + """Cleanup: shutdown the thread pool when the object is destroyed.""" + try: + self._executor.shutdown(wait=False) + except: + pass diff --git a/bot/modules/dom_management/__init__.py b/bot/modules/dom_management/__init__.py new file mode 100644 index 0000000..069b525 --- /dev/null +++ b/bot/modules/dom_management/__init__.py @@ -0,0 +1,208 @@ +from bot.module import Module +from bot import loaded_modules_dict + + +class DomManagement(Module): + # region Standard module stuff + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:] + }) + + setattr(self, "required_modules", [ + "module_dom", + "module_webserver" + ]) + + self.run_observer_interval = 5 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_dom_management" + + def setup(self, options=dict): + Module.setup(self, options) + # endregion + + # region Tools and workers + @staticmethod + def sanitize_for_html_id(value): + """ + Sanitize a string for use in HTML IDs. + Replaces spaces with underscores and converts to lowercase. + + Args: + value: String to sanitize + + Returns: + Sanitized string safe for HTML IDs + """ + return str(value).replace(" ", "_").lower() + + def occurrences_of_key_in_nested_mapping(self, key, value): + for k, v in value.items(): + if k == key: + yield v + elif isinstance(v, dict): + for result in self.occurrences_of_key_in_nested_mapping(key, v): + yield result + + def get_dict_element_by_path(self, d, l): + if len(l) == 1: + return d.get(l[0], []) + return self.get_dict_element_by_path(d.get(l[0], {}), l[1:]) + # endregion + + # region Template functions + @staticmethod + def get_selection_dom_element(*args, **kwargs): + module = args[0] + return module.template_render_hook( + module, + template=module.dom_management.templates.get_template('control_select_link.html'), + dom_element_select_root=kwargs.get("dom_element_select_root"), + target_module=kwargs.get("target_module"), + dom_element_entry_selected=kwargs.get("dom_element_entry_selected"), + dom_element=kwargs.get("dom_element"), + dom_action_inactive=kwargs.get("dom_action_inactive"), + dom_action_active=kwargs.get("dom_action_active") + ) + + @staticmethod + def get_delete_button_dom_element(*args, **kwargs): + module = args[0] + return module.template_render_hook( + module, + template=module.dom_management.templates.get_template('control_action_delete_button.html'), + count=kwargs.get("count"), + target_module=kwargs.get("target_module"), + dom_element_root=kwargs.get("dom_element_root"), + dom_element_select_root=kwargs.get("dom_element_select_root"), + dom_action=kwargs.get("dom_action"), + delete_selected_entries_active=kwargs.get("count") >= 1, + dom_element_id=kwargs.get("dom_element_id"), + confirmed=kwargs.get("confirmed", "False") + ) + + @staticmethod + def get_delete_confirm_modal(*args, **kwargs): + module = args[0] + return module.template_render_hook( + module, + template=module.dom_management.templates.get_template('modal_confirm_delete.html'), + count=kwargs.get("count"), + target_module=kwargs.get("target_module"), + dom_element_root=kwargs.get("dom_element_root"), + dom_element_select_root=kwargs.get("dom_element_select_root"), + dom_action=kwargs.get("dom_action"), + delete_selected_entries_active=kwargs.get("count") >= 1, + dom_element_id=kwargs.get("dom_element_id"), + confirmed=kwargs.get("confirmed", "False") + ) + + @staticmethod + def update_selection_status(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + target_module = kwargs.get("target_module", None) + dom_element_root = kwargs.get("dom_element_root", []) + dom_action_active = kwargs.get("dom_action_active", None) + dom_action_inactive = kwargs.get("dom_action_inactive", None) + dom_element_select_root = kwargs.get("dom_element_select_root", ["selected_by"]) + dom_element_id = kwargs.get("dom_element_id", None) + + # Use unsanitized dataset_original for DOM lookups (if available) + dom_element_origin = updated_values_dict.get("dataset_original", updated_values_dict.get("dataset")) + dom_element_owner = updated_values_dict["owner"] + + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + # getting the base root for all elements. it's always this path if the module wants to use these built + # in functions + dom_element = ( + module.dom.data + .get(target_module.get_module_identifier(), {}) + .get("elements", {}) + .get(dom_element_origin, {}) + .get(dom_element_owner, {}) + ) + + # get the individual element path, as provided by the module + for sub_dict in dom_element_root: + dom_element = dom_element.get(sub_dict) + + dom_element_is_selected_by = dom_element.get("selected_by", []) + dom_element_entry_selected = False + if dispatchers_steamid in dom_element_is_selected_by: + dom_element_entry_selected = True + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module=target_module.get_module_identifier(), + dom_element_select_root=dom_element_select_root, + dom_element=dom_element, + dom_element_entry_selected=dom_element_entry_selected, + dom_action_inactive=dom_action_inactive, + dom_action_active=dom_action_active + ) + + module.webserver.send_data_to_client_hook( + module, + payload=control_select_link, + data_type="element_content", + clients=[dispatchers_steamid], + method="update", + target_element=dom_element_id + ) + + def update_delete_button_status(self, *args, **kwargs): + module = args[0] + + target_module = kwargs.get("target_module", None) + dom_action = kwargs.get("dom_action", None) + dom_element_id = kwargs.get("dom_element_id", None) + + template_action_delete_button = module.dom_management.templates.get_template('control_action_delete_button.html') + + all_available_elements = ( + module.dom.data + .get(target_module.get_module_identifier(), {}) + .get("elements", {}) + ) + + for clientid in module.webserver.connected_clients.keys(): + all_selected_elements = 0 + for dom_element_is_selected_by in self.occurrences_of_key_in_nested_mapping( + "selected_by", + all_available_elements + ): + if clientid in dom_element_is_selected_by: + all_selected_elements += 1 + + data_to_emit = module.template_render_hook( + module, + template=template_action_delete_button, + dom_action=dom_action, + dom_element_root=kwargs.get("dom_element_root", []), + dom_element_select_root=kwargs.get("dom_element_select_root", []), + target_module=target_module.get_module_identifier(), + count=all_selected_elements, + delete_selected_entries_active=all_selected_elements >= 1, + dom_element_id=dom_element_id["id"], + confirmed=kwargs.get("confirmed", "False") + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="element_content", + clients=[clientid], + method="replace", + target_element=dom_element_id + ) + + # endregion + + +loaded_modules_dict[DomManagement().get_module_identifier()] = DomManagement() diff --git a/bot/modules/dom_management/actions/delete.py b/bot/modules/dom_management/actions/delete.py new file mode 100644 index 0000000..cdd2216 --- /dev/null +++ b/bot/modules/dom_management/actions/delete.py @@ -0,0 +1,66 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + target_module = event_data[1].get("target_module", None) + action_is_confirmed = event_data[1].get("confirmed", "False") + event_data[1]["action_identifier"] = action_name + + if action == "delete_selected_dom_elements": + if action_is_confirmed == "True": + stuff_to_delete = [] + for path, dom_element_key, dom_element in module.dom.get_dom_element_by_query( + target_module=target_module, + query="selected_by" + ): + if dispatchers_steamid in dom_element: + stuff_to_delete.append([target_module, "elements"] + path) + + for dom_element_to_delete in stuff_to_delete: + module.dom.data.remove_key_by_path( + dom_element_to_delete, + dispatchers_steamid=dispatchers_steamid + ) + + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + else: + loaded_modules_dict[target_module].set_current_view(dispatchers_steamid, { + "current_view": "delete-modal" + }) + return + + elif action == "cancel_delete_selected_dom_elements": + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + target_module = event_data[1].get("target_module", None) + loaded_modules_dict[target_module].set_current_view(dispatchers_steamid, { + "current_view": "frontend", + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "tools to help managing dom elements in the webinterface", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/dom_management/actions/select.py b/bot/modules/dom_management/actions/select.py new file mode 100644 index 0000000..eff73d8 --- /dev/null +++ b/bot/modules/dom_management/actions/select.py @@ -0,0 +1,120 @@ +from bot import loaded_modules_dict +from bot.logger import get_logger +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] +logger = get_logger("dom_management.select") + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + target_module = event_data[1].get("target_module", None) + event_data[1]["action_identifier"] = action_name + event_data[1]["fail_reason"] = [] + + dom_element_select_root = event_data[1].get("dom_element_select_root", ["selected_by"]) + dom_element_origin = event_data[1].get("dom_element_origin", None) + dom_element_owner = event_data[1].get("dom_element_owner", None) + dom_element_identifier = event_data[1].get("dom_element_identifier", None) + + if all([ + action is not None, + dom_element_origin is not None, + dom_element_owner is not None, + dom_element_identifier is not None + ]): + if action in [ # only proceed with known commands + "select_dom_element", + "deselect_dom_element" + ]: + general_root = [target_module, "elements", dom_element_origin, dom_element_owner] + full_root = general_root + dom_element_select_root + + selected_by_dict_element = module.dom_management.get_dict_element_by_path(module.dom.data, full_root) + + # CRITICAL: Make a COPY of the list! Otherwise we modify the original list, + # and then upsert sees old_value == new_value (both are references to the same list) + # This would cause the callback to NOT fire because method="unchanged" + selected_by_dict_element = list(selected_by_dict_element) + + try: + if action == "select_dom_element": + if dispatchers_steamid not in selected_by_dict_element: + selected_by_dict_element.append(dispatchers_steamid) + elif action == "deselect_dom_element": + if dispatchers_steamid in selected_by_dict_element: + selected_by_dict_element.remove(dispatchers_steamid) + except ValueError as error: + logger.error("select_list_manipulation_failed", + user=dispatchers_steamid, + action=action, + target_module=target_module, + origin=dom_element_origin, + owner=dom_element_owner, + identifier=dom_element_identifier, + error=str(error)) + + # Build data payload + data_payload = { + "selected_by": selected_by_dict_element, + "dataset": dom_element_origin, + "dataset_original": dom_element_origin, + "owner": dom_element_owner, + "identifier": dom_element_identifier + } + + # Build nested structure dynamically based on dom_element_select_root + # All keys except the last one (which is "selected_by") define nesting levels + nested_keys = dom_element_select_root[:-1] # Remove "selected_by" + + # Build nested dict from inside out + nested_data = data_payload + for key in reversed(nested_keys): + nested_data = {key: nested_data} + + module.dom.data.upsert({ + target_module: { + "elements": { + dom_element_origin: { + dom_element_owner: nested_data + } + } + } + }, dispatchers_steamid=dispatchers_steamid, min_callback_level=4) + + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + else: + event_data[1]["fail_reason"].append("unknown action") + else: + event_data[1]["fail_reason"].append("required options not set") + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +""" these will not be called directly. Always call the modules_callback and that will call this local function: +module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) +""" + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "handles selecting or deselecting an element in the dom for further actions", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/dom_management/templates/control_action_delete_button.html b/bot/modules/dom_management/templates/control_action_delete_button.html new file mode 100644 index 0000000..64de260 --- /dev/null +++ b/bot/modules/dom_management/templates/control_action_delete_button.html @@ -0,0 +1,13 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{% set count_string = count|string %} +
+{{ construct_toggle_link( + delete_selected_entries_active, + label|default("delete") + " (" + count_string + ")", ['widget_event', ['dom_management', ['delete', { + "target_module": target_module, + "dom_element_root": dom_element_root, + 'action': dom_action, + 'confirmed': confirmed + }]]] +) }} +
\ No newline at end of file diff --git a/bot/modules/dom_management/templates/control_select_link.html b/bot/modules/dom_management/templates/control_select_link.html new file mode 100644 index 0000000..aba3e17 --- /dev/null +++ b/bot/modules/dom_management/templates/control_select_link.html @@ -0,0 +1,24 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{% set owner = dom_element.owner|default("null") %} +{% set identifier = dom_element.identifier|default("null") %} +{% set dataset = dom_element.dataset|default("null") %} +{% set dataset_original = dom_element.dataset_original|default(dataset) %} +{{ construct_toggle_link( + dom_element_entry_selected, + "☑", ['widget_event', ['dom_management', ['select', { + "dom_element_select_root": dom_element_select_root, + "target_module": target_module, + "dom_element_owner": owner, + "dom_element_identifier": identifier, + "dom_element_origin": dataset_original, + 'action': dom_action_active + }]]], + "☐", ['widget_event', ['dom_management', ['select', { + "dom_element_select_root": dom_element_select_root, + "target_module": target_module, + "dom_element_owner": owner, + "dom_element_identifier": identifier, + "dom_element_origin": dataset_original, + "action": dom_action_inactive + }]]] +) }} \ No newline at end of file diff --git a/bot/modules/dom_management/templates/jinja2_macros.html b/bot/modules/dom_management/templates/jinja2_macros.html new file mode 100644 index 0000000..6dd9ab3 --- /dev/null +++ b/bot/modules/dom_management/templates/jinja2_macros.html @@ -0,0 +1,20 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ active_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- else -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ inactive_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- endif -%} +{%- endmacro -%} diff --git a/bot/modules/dom_management/templates/modal_confirm_delete.html b/bot/modules/dom_management/templates/modal_confirm_delete.html new file mode 100644 index 0000000..9f67214 --- /dev/null +++ b/bot/modules/dom_management/templates/modal_confirm_delete.html @@ -0,0 +1,75 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{% set count_string = count|string %} +
+
+

Make sure you've got the right stuff selected! Deletions can not be undone.

+
+
+

You have {{ count_string }} elements selected for deletion:

+
+
+

This will soon be an actual list of actual elements to delete

+
    +
  • Element 1
  • +
  • Element 2
  • +
  • Element 3
  • +
  • Element 4
  • +
  • Element 5
  • +
  • Element 6
  • +
  • Element 7
  • +
  • Element 8
  • +
  • Element 9
  • +
  • Element 10
  • +
  • Element 11
  • +
  • Element 12
  • +
  • Element 13
  • +
  • Element 14
  • +
  • Element 15
  • +
  • Element 16
  • +
  • Element 17
  • +
  • Element 18
  • +
  • Element 19
  • +
  • Element 20
  • +
  • Element 21
  • +
  • Element 22
  • +
  • Element 23
  • +
  • Element 24
  • +
  • Element 25
  • +
  • Element 26
  • +
  • Element 27
  • +
  • Element 28
  • +
  • Element 29
  • +
  • Element 30
  • +
  • Element 31
  • +
  • Element 32
  • +
  • Element 33
  • +
  • Element 34
  • +
  • Element 35
  • +
  • Element 36
  • +
  • Element 37
  • +
  • Element 38
  • +
  • Element 39
  • +
  • Element 40
  • +
+
+
+
+

+ By clicking [confirm] you will continue to proceed deleting + {{ count_string }} elements. +

+

+ {% include "modal_confirm_delete_confirm_button.html" %} +

+
+
+

+ Clicking [cancel] will abort the deletion process, + it will keep the selection intact. +

+

+ {% include "modal_confirm_delete_cancel_button.html" %} +

+
+
+
\ No newline at end of file diff --git a/bot/modules/dom_management/templates/modal_confirm_delete_cancel_button.html b/bot/modules/dom_management/templates/modal_confirm_delete_cancel_button.html new file mode 100644 index 0000000..faa3776 --- /dev/null +++ b/bot/modules/dom_management/templates/modal_confirm_delete_cancel_button.html @@ -0,0 +1,11 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + diff --git a/bot/modules/dom_management/templates/modal_confirm_delete_confirm_button.html b/bot/modules/dom_management/templates/modal_confirm_delete_confirm_button.html new file mode 100644 index 0000000..e3672a3 --- /dev/null +++ b/bot/modules/dom_management/templates/modal_confirm_delete_confirm_button.html @@ -0,0 +1,12 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + diff --git a/bot/modules/game_environment/__init__.py b/bot/modules/game_environment/__init__.py new file mode 100644 index 0000000..5e09c20 --- /dev/null +++ b/bot/modules/game_environment/__init__.py @@ -0,0 +1,101 @@ +from bot.module import Module +from bot import loaded_modules_dict +from time import time + + +class Environment(Module): + templates = object + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:], + "dom_element_root": [], + "dom_element_select_root": ["id"], + "run_observer_interval": 3 + }) + + setattr(self, "required_modules", [ + "module_dom", + "module_dom_management", + "module_telnet", + "module_webserver" + ]) + + self.next_cycle = 0 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_game_environment" + + def on_socket_connect(self, steamid): + Module.on_socket_connect(self, steamid) + + def on_socket_disconnect(self, steamid): + Module.on_socket_disconnect(self, steamid) + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + self.dom_element_root = self.options.get( + "dom_element_root", self.default_options.get("dom_element_root", None) + ) + self.dom_element_select_root = self.options.get( + "dom_element_select_root", self.default_options.get("dom_element_select_root", None) + ) + self.run_observer_interval = self.options.get( + "run_observer_interval", self.default_options.get("run_observer_interval", None) + ) + + # endregion + def get_last_recorded_gametime_dict(self): + active_dataset = self.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + return None + + return ( + self.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("last_recorded_gametime", {}) + ) + + def get_last_recorded_gametime_string(self): + last_recorded_gametime_dict = self.get_last_recorded_gametime_dict() + if last_recorded_gametime_dict is None: + return "Day {day}, {hour}:{minute}".format( + day="n/a", + hour="n/a", + minute="n/a" + ) + + return "Day {day}, {hour}:{minute}".format( + day=last_recorded_gametime_dict.get("day", "n/a"), + hour=last_recorded_gametime_dict.get("hour", "n/a"), + minute=last_recorded_gametime_dict.get("minute", "n/a") + ) + + def run(self): + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + self.trigger_action_hook(self, event_data=["getgameprefs", { + "disable_after_success": True + }]) + + # requires getgameprefs to be successful + self.trigger_action_hook(self, event_data=["getgamestats", { + "disable_after_success": True + }]) + + self.trigger_action_hook(self, event_data=["gettime", {}]) + + self.trigger_action_hook(self, event_data=["getentities", {}]) + + self.execute_telnet_triggers() + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + + +loaded_modules_dict[Environment().get_module_identifier()] = Environment() diff --git a/bot/modules/game_environment/actions/cancel_shutdown.py b/bot/modules/game_environment/actions/cancel_shutdown.py new file mode 100644 index 0000000..2252906 --- /dev/null +++ b/bot/modules/game_environment/actions/cancel_shutdown.py @@ -0,0 +1,49 @@ +from bot import loaded_modules_dict +from os import path, pardir +from time import time + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + module.dom.data.upsert({ + "module_game_environment": { + active_dataset: { + "cancel_shutdown": True, + "shutdown_in_seconds": None, + "force_shutdown": False + } + } + }) + + event_data = ['say_to_all', { + 'message': ( + 'a [FF6666]scheduled shutdown[-] has been cancelled.' + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + """ stop the timer """ + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Set the (active) shutdown procedure to be cancelled", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/force_shutdown.py b/bot/modules/game_environment/actions/force_shutdown.py new file mode 100644 index 0000000..fbe891a --- /dev/null +++ b/bot/modules/game_environment/actions/force_shutdown.py @@ -0,0 +1,52 @@ +from bot import loaded_modules_dict +from os import path, pardir +from time import time + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + module.dom.data.upsert({ + "module_game_environment": { + active_dataset: { + "cancel_shutdown": False, + "shutdown_in_seconds": None, + "force_shutdown": True + } + } + }) + + event_data = ['say_to_all', { + 'message': ( + '[FF6666]FORCED SHUTDOWN INITIATED[-]' + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + """ stop the timer """ + + event_data = ['shutdown', {}] + module.trigger_action_hook(module, event_data=event_data) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Set the (active) shutdown procedure to be force-completed!", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/getentities.py b/bot/modules/game_environment/actions/getentities.py new file mode 100644 index 0000000..b180ba6 --- /dev/null +++ b/bot/modules/game_environment/actions/getentities.py @@ -0,0 +1,136 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_NORMAL +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_NORMAL + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + if not module.telnet.add_telnet_command_to_queue("listents"): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format - matches both empty and populated entity lists + regex = ( + r"Executing\scommand\s\'listents\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P[\s\S]*?)" + r"Total\sof\s(?P\d{1,3})\sin\sthe\sgame" + ) + + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + raw_entity_data = match.group("raw_entity_data").lstrip() + if len(raw_entity_data) >= 1: + regex = ( + r"\d{1,2}. id=(?P\d+), \[" + r"type=(?P.+), " + r"name=(?P.*), " + r"id=(\d+)" + r"\], " + r"pos=\((?P.?\d+.\d), (?P.?\d+.\d), (?P.?\d+.\d)\), " + r"rot=\((?P.?\d+.\d), (?P.?\d+.\d), (?P.?\d+.\d)\), " + r"lifetime=(?P.+), " + r"remote=(?P.+), " + r"dead=(?P.+), " + r"health=(?P\d+)" + r"\r\n" + ) + entities_to_update_dict = {} + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + return + + for m in re.finditer(regex, raw_entity_data): + last_seen_gametime_string = module.game_environment.get_last_recorded_gametime_string() + entity_dict = { + "id": m.group("id"), + "owner": m.group("id"), + "identifier": m.group("id"), + "type": str(m.group("type")), + "name": str(m.group("name")), + "pos": { + "x": int(float(m.group("pos_x"))), + "y": int(float(m.group("pos_y"))), + "z": int(float(m.group("pos_z"))), + }, + "rot": { + "x": int(float(m.group("rot_x"))), + "y": int(float(m.group("rot_y"))), + "z": int(float(m.group("rot_z"))), + }, + "lifetime": str(m.group("lifetime")), + "remote": bool(m.group("remote")), + "dead": bool(m.group("dead")), + "health": int(m.group("health")), + "dataset": active_dataset, + "last_seen_gametime": last_seen_gametime_string + } + entities_to_update_dict[m.group("id")] = entity_dict + + if len(entities_to_update_dict) >= 1: + module.dom.data.upsert({ + module.get_module_identifier(): { + "elements": { + active_dataset: entities_to_update_dict + } + } + }) + + stuff_to_delete = [] + for path, dom_element_key, dom_element in module.dom.get_dom_element_by_query( + target_module=module.get_module_identifier(), + query="id" + ): + # Delete entities that are no longer in the update or have health <= 0 (dead) + entity_data = entities_to_update_dict.get(dom_element, {}) + health = entity_data.get("health", 0) + if dom_element not in entities_to_update_dict or health <= 0: + stuff_to_delete.append([module.get_module_identifier(), "elements"] + path) + + for dom_element_to_delete in stuff_to_delete: + module.dom.data.remove_key_by_path( + dom_element_to_delete, + dispatchers_steamid=dispatchers_steamid + ) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +def skip_it(module, event_data, dispatchers_steamid=None): + pass + + +action_meta = { + "description": "get game entities", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "skip_it": skip_it, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/getgameprefs.py b/bot/modules/game_environment/actions/getgameprefs.py new file mode 100644 index 0000000..ffec8b2 --- /dev/null +++ b/bot/modules/game_environment/actions/getgameprefs.py @@ -0,0 +1,108 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_NORMAL +from bot.logger import get_logger +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] +logger = get_logger("game_environment.getgameprefs") + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_NORMAL + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + if not module.telnet.add_telnet_command_to_queue("getgamepref"): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + # Modern format: timestamps ARE present in "Executing command" lines + # Format: 2025-11-18T20:21:02 4854.528 INF Executing command 'getgamepref'... + regex = ( + r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s(?P[-+]?\d*\.\d+|\d+)\s" + r"INF Executing\scommand\s\'getgamepref\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P(?:GamePref\..*?\r?\n)+)" + ) + + match = None + match_found = False + poll_is_finished = False + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.MULTILINE): + poll_is_finished = True + match_found = True + + if match_found: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def validate_settings(regex, raw_gameprefs): + gameprefs_dict = {} + all_required_settings_are_available = False + for m in re.finditer(regex, raw_gameprefs, re.MULTILINE): + stripped_gameprefs = m.group("gamepref_value").rstrip() + if all([ + len(stripped_gameprefs) >= 1, # we have settings + m.group("gamepref_name") == "GameName" # the GameName setting is available! + + ]): + all_required_settings_are_available = True + + gameprefs_dict[m.group("gamepref_name")] = stripped_gameprefs + + if all_required_settings_are_available: + return gameprefs_dict + else: + return False + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + regex = ( + r"GamePref\.(?P.*)\s\=\s(?P.*)\s" + ) + raw_gameprefs = match.group("raw_gameprefs") + + gameprefs_dict = validate_settings(regex, raw_gameprefs) + if isinstance(gameprefs_dict, dict): + current_game_name = gameprefs_dict.get("GameName", None) + module.dom.data.upsert({ + module.get_module_identifier(): { + current_game_name: { + "gameprefs": gameprefs_dict + } + } + }) + + module.dom.data.upsert({ + module.get_module_identifier(): { + "active_dataset": current_game_name + } + }) + + logger.info("active_dataset_set", dataset=current_game_name) + else: + logger.error("gameprefs_validation_failed", reason="required_settings_missing") + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "gets a list of all current game-preferences", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/getgamestats.py b/bot/modules/game_environment/actions/getgamestats.py new file mode 100644 index 0000000..47a5be3 --- /dev/null +++ b/bot/modules/game_environment/actions/getgamestats.py @@ -0,0 +1,88 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_NORMAL +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + # we can't save the gamestats without knowing the game-name, as each game can have different stats. + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + timeout = TELNET_TIMEOUT_NORMAL + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + if not module.telnet.add_telnet_command_to_queue("getgamestat"): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + # Modern format: timestamps ARE present in "Executing command" lines + # Format: 2025-11-18T20:21:02 4854.528 INF Executing command 'getgamestat'... + regex = ( + r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s(?P[-+]?\d*\.\d+|\d+)\s" + r"INF Executing\scommand\s\'getgamestat\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P(?:GameStat\..*?\r?\n)+)" + ) + + match = None + match_found = False + poll_is_finished = False + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) # give the telnet a little time to respond so we have a chance to get the data at first try + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.MULTILINE): + poll_is_finished = True + match_found = True + + if match_found: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + regex = ( + r"GameStat\.(?P.*)\s\=\s(?P.*)\s" + ) + raw_gamestats = match.group("raw_gamestats") + gamestats_dict = {} + + for m in re.finditer(regex, raw_gamestats, re.MULTILINE): + gamestats_dict[m.group("gamestat_name")] = m.group("gamestat_value").rstrip() + + active_dataset = ( + module.dom.data + .get(module.get_module_identifier()) + .get("active_dataset", None) + ) + + module.dom.data.upsert({ + module.get_module_identifier(): { + active_dataset: { + "gamestats": gamestats_dict + } + } + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "gets a list of all current game-stats", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/gettime.py b/bot/modules/game_environment/actions/gettime.py new file mode 100644 index 0000000..ed6468c --- /dev/null +++ b/bot/modules/game_environment/actions/gettime.py @@ -0,0 +1,151 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_NORMAL +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def get_weekday_string(server_days_passed: int) -> str: + days_of_the_week = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ] + + current_day_index = int(float(server_days_passed) % 7) + if 0 <= current_day_index <= 6: + return days_of_the_week[current_day_index] + else: + return "" + + +def is_currently_bloodmoon(module: object, day: int, hour: int = -1) -> bool: + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + next_bloodmoon_date = int( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gamestats", {}) + .get("BloodMoonDay", None) + ) + + daylight_length = int( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gamestats", {}) + .get("DayLightLength", None) + ) + + night_length = (24 - daylight_length) + morning_length = (night_length - 2) + + if hour >= 0: # we want the exact bloodmoon + if next_bloodmoon_date == day and 23 >= hour >= 22: + return True + if (next_bloodmoon_date + 1) == day and 0 <= hour <= morning_length: + return True + else: # we only want the day + if next_bloodmoon_date == day: + return True + + return False + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_NORMAL + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + if not module.telnet.add_telnet_command_to_queue("gettime"): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format: simple "Day 447, 00:44" response + regex = r"Day\s(?P\d{1,5}),\s(?P\d{1,2}):(?P\d{1,2})" + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + active_dataset = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("active_dataset", None) + ) + + # we can't save the gametime without knowing the game-name, as each game can have different stats. + if active_dataset is None: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + matched_day = int(match.group("day")) + matched_hour = match.group("hour") + matched_minute = match.group("minute") + + is_bloodmoon = is_currently_bloodmoon(module, matched_day, int(matched_hour)) + is_bloodday = is_currently_bloodmoon(module, matched_day) + + weekday_string = get_weekday_string(matched_day) + + module.dom.data.upsert({ + module.get_module_identifier(): { + active_dataset: { + "last_recorded_gametime": { + "day": matched_day, + "hour": matched_hour, + "minute": matched_minute, + "weekday": weekday_string, + "is_bloodmoon": is_bloodmoon, + "is_bloodday": is_bloodday + } + } + } + }) + + # Update last_recorded_servertime for webserver status widget + # Since modern 7D2D servers don't include timestamps in telnet output, + # we use system time to track when data was last received + from datetime import datetime + module.dom.data.upsert({ + "module_telnet": { + "last_recorded_servertime": datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + } + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +def skip_it(module, event_data, dispatchers_steamid=None): + pass + + +action_meta = { + "description": "gets the current gettime readout", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "skip_it": skip_it, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/manage_entities.py b/bot/modules/game_environment/actions/manage_entities.py new file mode 100644 index 0000000..5b13616 --- /dev/null +++ b/bot/modules/game_environment/actions/manage_entities.py @@ -0,0 +1,102 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_EXTENDED +from os import path, pardir +from time import time, sleep +import random +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def kill_entity(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_EXTENDED + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + entity_to_be_killed = event_data[1].get("entity_id", None) + + command = "kill {}".format(entity_to_be_killed) + if not module.telnet.add_telnet_command_to_queue(command): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format - no datetime/stardate prefix + regex = ( + r"Entity\s(?P.*)\s" + str(entity_to_be_killed) + r"\skilled" + ) + number_of_attempts = 0 + while not poll_is_finished and (time() < timeout_start + timeout): + number_of_attempts += 1 + telnet_buffer_copy = (module.telnet.telnet_buffer + '.')[:-1] + for match in re.finditer(regex, telnet_buffer_copy, re.DOTALL): + return match + + sleep(1) + + return False + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + dataset = event_data[1].get("dataset") + entity_id = event_data[1].get("entity_id") + entity_name = event_data[1].get("entity_name") + + if action is not None: + if action == "kill": + match = kill_entity(module, event_data, dispatchers_steamid) + if match is not False: + if entity_name == "zombieScreamer": + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + action = event_data[1].get("action", None) + entity_id = event_data[1].get("entity_id") + entity_name = event_data[1].get("entity_name") + + if all([ + action is not None + ]): + if entity_name == "zombieScreamer": + possible_maybes = [ + "hopefully", + "probably dead, yes!", + "i think", + "i'm almost certain", + "yeah. definitely!!" + ] + event_data = ['say_to_all', { + 'message': '[CCFFCC]Screamer ([FFFFFF]{entity_id}[CCFFCC]) killed[-], [FFFFFF]{maybe}[-]...'.format( + entity_id=entity_id, + maybe=random.choice(possible_maybes) + ) + }] + module.trigger_action_hook(module, event_data=event_data) + else: + event_data = ['say_to_all', { + 'message': '[CCFFCC]entity ([FFFFFF]{entity_id}[CCFFCC]) killed[-]'.format( + entity_id=entity_id + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages entity entries", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/say_to_all.py b/bot/modules/game_environment/actions/say_to_all.py new file mode 100644 index 0000000..f15c9b3 --- /dev/null +++ b/bot/modules/game_environment/actions/say_to_all.py @@ -0,0 +1,59 @@ +from bot import loaded_modules_dict, telnet_prefixes +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = 5 # [seconds] + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + message = event_data[1].get("message", None) + + command = "say \"{}\"".format(message) + + if not module.telnet.add_telnet_command_to_queue(command): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format: timestamps ARE present in "Executing command" lines + regex = ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Executing\scommand\s\'" + command + r"\'\sby\sTelnet\sfrom\s(?P.*)" + ) + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "sends a message to any player", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/schedule_shutdown.py b/bot/modules/game_environment/actions/schedule_shutdown.py new file mode 100644 index 0000000..47e8b49 --- /dev/null +++ b/bot/modules/game_environment/actions/schedule_shutdown.py @@ -0,0 +1,50 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + shutdown_in_seconds = int(event_data[1]["shutdown_in_seconds"]) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + module.dom.data.upsert({ + "module_game_environment": { + active_dataset: { + "cancel_shutdown": False, + "shutdown_in_seconds": shutdown_in_seconds, + "force_shutdown": False + } + } + }) + + event_data = ['say_to_all', { + 'message': ( + 'a [FF6666]scheduled shutdown[-] is about to take place!' + 'shutdown in {seconds} seconds'.format( + seconds=shutdown_in_seconds + ) + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Sets the schedule for a shutdown", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/shutdown.py b/bot/modules/game_environment/actions/shutdown.py new file mode 100644 index 0000000..72539ee --- /dev/null +++ b/bot/modules/game_environment/actions/shutdown.py @@ -0,0 +1,70 @@ +from bot import loaded_modules_dict +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + timeout = 10 + + if not module.telnet.add_telnet_command_to_queue("shutdown"): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format - no datetime/stardate prefix, just look for "Disconnect" + regex = r"Disconnect.*" + + timeout_start = time() + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.5) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + module.dom.data.upsert({ + "module_game_environment": { + active_dataset: { + "cancel_shutdown": False, + "shutdown_in_seconds": None, + "force_shutdown": False + } + } + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + module.dom.data.upsert({ + "module_game_environment": { + active_dataset: { + "cancel_shutdown": False, + "shutdown_in_seconds": None, + "force_shutdown": False + } + } + }) + + +action_meta = { + "description": "Cleanly shuts down the server", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/toggle_entities_widget_view.py b/bot/modules/game_environment/actions/toggle_entities_widget_view.py new file mode 100644 index 0000000..b31ef8c --- /dev/null +++ b/bot/modules/game_environment/actions/toggle_entities_widget_view.py @@ -0,0 +1,46 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + + if action == "show_options": + current_view = "options" + current_view_steamid = None + elif action == "show_frontend": + current_view = "frontend" + current_view_steamid = None + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + module.set_current_view(dispatchers_steamid, { + "current_view": current_view, + "current_view_steamid": current_view_steamid + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages entity table stuff", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/actions/update_bloodmoon_date.py b/bot/modules/game_environment/actions/update_bloodmoon_date.py new file mode 100644 index 0000000..70fdb12 --- /dev/null +++ b/bot/modules/game_environment/actions/update_bloodmoon_date.py @@ -0,0 +1,51 @@ +from bot import loaded_modules_dict +from os import path, pardir + + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + event_data[1]["action_identifier"] = action_name + next_bloodmoon_date = event_data[1].get("blood_moon_date", None) + if next_bloodmoon_date is not None: + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + next_bloodmoon_date = event_data[1].get("blood_moon_date", None) + + module.dom.data.upsert({ + module.get_module_identifier(): { + active_dataset: { + "gamestats": { + "BloodMoonDay": next_bloodmoon_date + } + } + } + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +def skip_it(module, event_data, dispatchers_steamid=None): + pass + + +action_meta = { + "description": "updates bloodmoon date", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "skip_it": skip_it, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/game_environment/commands/when_is_hordenight.py b/bot/modules/game_environment/commands/when_is_hordenight.py new file mode 100644 index 0000000..dd68b79 --- /dev/null +++ b/bot/modules/game_environment/commands/when_is_hordenight.py @@ -0,0 +1,55 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + next_bloodmoon_date = ( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gamestats", {}) + .get("BloodMoonDay", None) + ) + event_data = ['say_to_all', { + 'message': 'Next [FFCCCC]hordenight[FFFFFF] will be on day {day}[-]'.format( + day=next_bloodmoon_date + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + +triggers = { + "when is hordenight": r"\'(?P.*)\'\:\s(?P\/when\sis\shordenight)" +} + +trigger_meta = { + "description": "tells the player when the next bloodmoon will hit", + "main_function": main_function, + "triggers": [ + { + "identifier": "when is hordenight (Allocs)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["when is hordenight"] + ), + "callback": main_function + }, + { + "identifier": "when is hordenight (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["when is hordenight"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/game_environment/templates/gameserver_status_widget/view_frontend.html b/bot/modules/game_environment/templates/gameserver_status_widget/view_frontend.html new file mode 100644 index 0000000..3ea9511 --- /dev/null +++ b/bot/modules/game_environment/templates/gameserver_status_widget/view_frontend.html @@ -0,0 +1,33 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{# set this to 'false' for debug purposes, to see the offline state, set it to 'server_is_online' or remove the line + for regular use #} +{%- set server_is_online = server_is_online -%} +{%- set online_status_string = "online" if server_is_online else "offline" -%} + +
+ The server is {{ online_status_string }} + {% if shutdown_in_seconds == none -%} + {{ construct_toggle_link( + server_is_online, + "shutdown", ['widget_event', ['game_environment', ['schedule_shutdown', { + "action": "schedule_shutdown", + "shutdown_in_seconds": '900' + }]]] + )}} + {%- else -%} + {{ construct_toggle_link( + server_is_online, + "cancel", ['widget_event', ['game_environment', ['cancel_shutdown', { + "action": "cancel_shutdown" + }]]] + )}} + {{ shutdown_in_seconds }} seconds to + {{ construct_toggle_link( + server_is_online, + "shutdown", ['widget_event', ['game_environment', ['force_shutdown', { + "action": "force_shutdown" + }]]] + )}} + {%- endif -%} + +
diff --git a/bot/modules/game_environment/templates/gametime_widget/view_frontend.html b/bot/modules/game_environment/templates/gametime_widget/view_frontend.html new file mode 100644 index 0000000..38fa24d --- /dev/null +++ b/bot/modules/game_environment/templates/gametime_widget/view_frontend.html @@ -0,0 +1,19 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{% set current_day = last_recorded_gametime["day"] %} +{% set current_hour = last_recorded_gametime["hour"] %} +{% set current_minute = last_recorded_gametime["minute"] %} +{% + set current_weekday = + "BloodDay" + if last_recorded_gametime["is_bloodday"] == true else + last_recorded_gametime["weekday"] +%} +{% set bloodmoon_modifier = "is_bloodmoon" if last_recorded_gametime["is_bloodmoon"] == true else "regular_gametime" %} +{% set bloodday_modifier = "is_bloodday" if last_recorded_gametime["is_bloodday"] == true else "regular_day" %} +
+ + Day {{ current_day }}/{{ next_bloodmoon_date }}, + {{ current_hour }}:{{ current_minute }} + ({{ current_weekday }}) + +
\ No newline at end of file diff --git a/bot/modules/game_environment/templates/jinja2_macros.html b/bot/modules/game_environment/templates/jinja2_macros.html new file mode 100644 index 0000000..3aea3eb --- /dev/null +++ b/bot/modules/game_environment/templates/jinja2_macros.html @@ -0,0 +1,20 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ active_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- else -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ inactive_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- endif -%} +{%- endmacro -%} \ No newline at end of file diff --git a/bot/modules/game_environment/templates/manage_entities_widget/control_switch_options_view.html b/bot/modules/game_environment/templates/manage_entities_widget/control_switch_options_view.html new file mode 100644 index 0000000..cf493b8 --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/control_switch_options_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + options_view_toggle, + "options", ['widget_event', ['game_environment', ['toggle_entities_widget_view', {'steamid': steamid, "action": "show_options"}]]], + "back", ['widget_event', ['game_environment', ['toggle_entities_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/game_environment/templates/manage_entities_widget/control_switch_view.html b/bot/modules/game_environment/templates/manage_entities_widget/control_switch_view.html new file mode 100644 index 0000000..b737b98 --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/control_switch_view.html @@ -0,0 +1,3 @@ +
+ {{ control_switch_options_view }} +
\ No newline at end of file diff --git a/bot/modules/game_environment/templates/manage_entities_widget/table_footer.html b/bot/modules/game_environment/templates/manage_entities_widget/table_footer.html new file mode 100644 index 0000000..4c0b886 --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/table_footer.html @@ -0,0 +1,7 @@ + + +
+ {{ action_delete_button }} +
+ + \ No newline at end of file diff --git a/bot/modules/game_environment/templates/manage_entities_widget/table_header.html b/bot/modules/game_environment/templates/manage_entities_widget/table_header.html new file mode 100644 index 0000000..4c9b0fd --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/table_header.html @@ -0,0 +1,10 @@ + + * + actions + name + type + pos + id + health + gametime + \ No newline at end of file diff --git a/bot/modules/game_environment/templates/manage_entities_widget/table_row.html b/bot/modules/game_environment/templates/manage_entities_widget/table_row.html new file mode 100644 index 0000000..564a99e --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/table_row.html @@ -0,0 +1,25 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + + + {{ control_select_link }} + +   + {{ entity.name }} + {{ entity.type }} + + + {{ ((entity | default({})).pos | default({}) ).x | default('0') }} + + + {{ ((entity | default({})).pos | default({}) ).y | default('0') }} + + + {{ ((entity | default({})).pos | default({}) ).z | default('0') }} + + + {{ entity.id }} + {{ entity.health }} + + {{ entity.last_seen_gametime }} + + diff --git a/bot/modules/game_environment/templates/manage_entities_widget/view_frontend.html b/bot/modules/game_environment/templates/manage_entities_widget/view_frontend.html new file mode 100644 index 0000000..09d8a03 --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/view_frontend.html @@ -0,0 +1,29 @@ +
+
+ Entities +
+
+ +
+ + + + {{ table_header }} + + + {{ table_rows }} + + + {{ table_footer }} + +
+ obey +
+
+ +
+
\ No newline at end of file diff --git a/bot/modules/game_environment/templates/manage_entities_widget/view_options.html b/bot/modules/game_environment/templates/manage_entities_widget/view_options.html new file mode 100644 index 0000000..ed510eb --- /dev/null +++ b/bot/modules/game_environment/templates/manage_entities_widget/view_options.html @@ -0,0 +1,27 @@ +
+
+ Entities +
+
+ +
+ + + + + + + + + + + {% for key, value in widget_options.items() %} + + + + {% endfor %} + +
Entity Module Options
widget-options
{{key}}{{value}}
+
\ No newline at end of file diff --git a/bot/modules/game_environment/triggers/new_bloodmoon.py b/bot/modules/game_environment/triggers/new_bloodmoon.py new file mode 100644 index 0000000..07dc502 --- /dev/null +++ b/bot/modules/game_environment/triggers/new_bloodmoon.py @@ -0,0 +1,35 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + next_blood_moon = regex_result.group("next_BloodMoon") + event_data = ['update_bloodmoon_date', { + 'blood_moon_date': next_blood_moon, + }] + module.trigger_action_hook(origin_module, event_data=event_data) + + +triggers = { + "BloodMoon SetDay": r"BloodMoon\sSetDay:\sday\s(?P\d+)" +} + +trigger_meta = { + "description": "reacts to updated BloodMoon date in the telnet-stream", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + triggers["BloodMoon SetDay"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/game_environment/triggers/shutdown_handler.py b/bot/modules/game_environment/triggers/shutdown_handler.py new file mode 100644 index 0000000..be1ad98 --- /dev/null +++ b/bot/modules/game_environment/triggers/shutdown_handler.py @@ -0,0 +1,33 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from bot.logger import get_logger +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] +logger = get_logger("game_environment.shutdown_handler") + + +def main_function(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + + cancel_shutdown = updated_values_dict.get("cancel_shutdown", None) + force_shutdown = updated_values_dict.get("force_shutdown", None) + + if cancel_shutdown: + logger.info("shutdown_cancelled") + if force_shutdown: + logger.info("shutdown_forced") + + +trigger_meta = { + "description": "reacts to changes in the shutdown procedure", + "main_function": main_function, + "handlers": { + "module_game_environment/%map_identifier%/cancel_shutdown": main_function, + "module_game_environment/%map_identifier%/force_shutdown": main_function + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/game_environment/widgets/gameserver_status_widget.py b/bot/modules/game_environment/widgets/gameserver_status_widget.py new file mode 100644 index 0000000..e620469 --- /dev/null +++ b/bot/modules/game_environment/widgets/gameserver_status_widget.py @@ -0,0 +1,88 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def main_widget(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + template_frontend = module.templates.get_template('gameserver_status_widget/view_frontend.html') + + server_is_online = module.dom.data.get("module_telnet", {}).get("server_is_online", True) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + shutdown_in_seconds = ( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("shutdown_in_seconds", None) + ) + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + server_is_online=server_is_online, + shutdown_in_seconds=shutdown_in_seconds + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "gameserver_status_widget", + "type": "div", + "selector": "body > header > div > div" + } + ) + + +def update_widget(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + template_frontend = module.templates.get_template('gameserver_status_widget/view_frontend.html') + + server_is_online = module.dom.get_updated_or_default_value( + "module_telnet", "server_is_online", updated_values_dict, True + ) + + shutdown_in_seconds = module.dom.get_updated_or_default_value( + "module_game_environment", "shutdown_in_seconds", updated_values_dict, None + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + server_is_online=server_is_online, + shutdown_in_seconds=shutdown_in_seconds + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=module.webserver.connected_clients.keys(), + target_element={ + "id": "gameserver_status_widget", + "type": "div", + "selector": "body > header > div > div" + } + ) + + +widget_meta = { + "description": "shows gameserver status, shut it down. or don't ^^", + "main_widget": main_widget, + "handlers": { + "module_telnet/server_is_online": update_widget, + "module_game_environment/%map_identifier%/shutdown_in_seconds": update_widget, + "module_game_environment/%map_identifier%/cancel_shutdown": update_widget, + "module_game_environment/%map_identifier%/force_shutdown": update_widget + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/game_environment/widgets/gettime_widget.py b/bot/modules/game_environment/widgets/gettime_widget.py new file mode 100644 index 0000000..09affe3 --- /dev/null +++ b/bot/modules/game_environment/widgets/gettime_widget.py @@ -0,0 +1,103 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def main_widget(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + template_frontend = module.templates.get_template('gametime_widget/view_frontend.html') + gametime = module.game_environment.get_last_recorded_gametime_dict() + gametime.update({ + "is_bloodmoon": "", + "is_bloodday": "" + }) + + next_bloodmoon_date = ( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gamestats", {}) + .get("BloodMoonDay", None) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + last_recorded_gametime=gametime, + next_bloodmoon_date=next_bloodmoon_date + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "gametime_widget", + "type": "div", + "selector": "body > header > div > div" + } + ) + + +def update_widget(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + original_values_dict = kwargs.get("original_values_dict", None) + + gametime = updated_values_dict.get("last_recorded_gametime", None) + old_gametime = original_values_dict.get("last_recorded_gametime", None) + if gametime is None: + module.trigger_action_hook(module, event_data=["gettime", {}]) + return False + + if gametime == old_gametime: + pass + # return + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + next_bloodmoon_date = ( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gamestats", {}) + .get("BloodMoonDay", None) + ) + + template_frontend = module.templates.get_template('gametime_widget/view_frontend.html') + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + last_recorded_gametime=gametime, + next_bloodmoon_date=next_bloodmoon_date + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=module.webserver.connected_clients.keys(), + target_element={ + "id": "gametime_widget", + "type": "div", + "selector": "body > header > div > div" + } + ) + return gametime + + +widget_meta = { + "description": "displays the in-game time and day", + "main_widget": main_widget, + "handlers": { + "module_game_environment/%map_identifier%/last_recorded_gametime": update_widget + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/game_environment/widgets/location_change_announcer_widget.py b/bot/modules/game_environment/widgets/location_change_announcer_widget.py new file mode 100644 index 0000000..b2cf88f --- /dev/null +++ b/bot/modules/game_environment/widgets/location_change_announcer_widget.py @@ -0,0 +1,94 @@ +from bot import loaded_modules_dict +from os import path, pardir +from bot.logger import get_logger + +logger = get_logger(__name__) + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def announce_location_change(*args, **kwargs): + """ + Handler that announces location edits in the map chat. + This demonstrates that the callback_dict system works - multiple modules can + subscribe to the same DOM path changes without modifying the locations module. + """ + module = args[0] + method = kwargs.get("method", None) + updated_values_dict = kwargs.get("updated_values_dict", {}) + + # DEBUG: Log what we received + logger.info( + "location_change_debug", + method=method, + updated_values_dict_type=type(updated_values_dict).__name__, + updated_values_dict_repr=repr(updated_values_dict)[:200] + ) + + # Only announce on update, not insert or remove + if method != "update": + return + + if not isinstance(updated_values_dict, dict): + return + + # Get active dataset (map identifier) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + return + + # updated_values_dict structure at this depth (callback on depth 4): + # {location_identifier: {location_data}} + # location_data includes "owner" field + + for location_identifier, location_dict in updated_values_dict.items(): + if not isinstance(location_dict, dict): + continue + + # Get owner directly from location_dict + owner_steamid = location_dict.get("owner") + if owner_steamid is None: + logger.warning( + "location_owner_missing", + location_identifier=location_identifier + ) + continue + + # Get player name from DOM + player_name = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(owner_steamid, {}) + .get("name", "Unknown Player") + ) + + # Get location name + location_name = location_dict.get("name", location_identifier) + + # Send chat message via say_to_all + event_data = ['say_to_all', { + 'message': ( + '[FFAA00]Location Update:[-] {player} edited location [00FFFF]{location}[-]' + .format( + player=player_name, + location=location_name + ) + ) + }] + module.trigger_action_hook(module, event_data=event_data) + + +widget_meta = { + "description": "Announces location changes in map chat (test for callback_dict system)", + "main_widget": None, # No UI widget, just a handler + "handlers": { + # Subscribe to location changes - any module can do this! + "module_locations/elements/%map_identifier%/%owner_steamid%/%element_identifier%": announce_location_change + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/game_environment/widgets/manage_entities_widget.py b/bot/modules/game_environment/widgets/manage_entities_widget.py new file mode 100644 index 0000000..ff7d6b2 --- /dev/null +++ b/bot/modules/game_environment/widgets/manage_entities_widget.py @@ -0,0 +1,406 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def get_entity_table_row_css_class(entity_dict): + css_classes = [] + return " ".join(css_classes) + + +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + current_view = module.get_current_view(dispatchers_steamid) + + if current_view == "options": + options_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "delete-modal": + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + delete_modal_view(module, dispatchers_steamid=dispatchers_steamid) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + + +def delete_modal_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + all_available_entity_dicts = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + all_selected_elements_count = 0 + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + for map_identifier, entity_dicts in all_available_entity_dicts.items(): + if active_dataset == map_identifier: + for entity_id, entity_dict in entity_dicts.items(): + entity_is_selected_by = entity_dict.get("selected_by", []) + if dispatchers_steamid in entity_is_selected_by: + all_selected_elements_count += 1 + + modal_confirm_delete = module.dom_management.get_delete_confirm_modal( + module, + count=all_selected_elements_count, + target_module="module_game_environment", + dom_element_id="entity_table_modal_action_delete_button", + dom_action="delete_selected_dom_elements", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + confirmed="True" + ) + + data_to_emit = modal_confirm_delete + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="modal_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_entities_widget_modal", + "type": "div", + "selector": "body > main > div" + } + ) + + +def frontend_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('manage_entities_widget/view_frontend.html') + template_table_rows = module.templates.get_template('manage_entities_widget/table_row.html') + template_table_header = module.templates.get_template('manage_entities_widget/table_header.html') + template_table_footer = module.templates.get_template('manage_entities_widget/table_footer.html') + + template_options_toggle = module.templates.get_template('manage_entities_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template('manage_entities_widget/control_switch_options_view.html') + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + all_available_entity_dicts = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + + table_rows = "" + all_selected_elements_count = 0 + for map_identifier, entity_dicts in all_available_entity_dicts.items(): + if active_dataset == map_identifier: + for entity_id, entity_dict in entity_dicts.items(): + entity_is_selected_by = entity_dict.get("selected_by", []) + + entity_entry_selected = False + if dispatchers_steamid in entity_is_selected_by: + entity_entry_selected = True + all_selected_elements_count += 1 + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_game_environment", + dom_element_select_root=["selected_by"], + dom_element=entity_dict, + dom_element_entry_selected=entity_entry_selected, + dom_action_inactive="select_dom_element", + dom_action_active="deselect_dom_element" + ) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(entity_dict.get("dataset", "")) + sanitized_entity_id = str(entity_id) + + # Update entity_dict with sanitized values for template + entity_dict_for_template = entity_dict.copy() + entity_dict_for_template["dataset"] = sanitized_dataset + entity_dict_for_template["dataset_original"] = entity_dict.get("dataset", "") + + table_rows += module.template_render_hook( + module, + template=template_table_rows, + entity=entity_dict_for_template, + css_class=get_entity_table_row_css_class(entity_dict), + control_select_link=control_select_link + ) + + current_view = module.get_current_view(dispatchers_steamid) + + options_toggle = module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + options_view_toggle=(current_view in ["frontend", "delete-modal"]), + steamid=dispatchers_steamid + ) + ) + + dom_element_delete_button = module.dom_management.get_delete_button_dom_element( + module, + count=all_selected_elements_count, + target_module="module_game_environment", + dom_element_id="entity_table_widget_action_delete_button", + dom_action="delete_selected_dom_elements", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + table_header=module.template_render_hook( + module, + template=template_table_header + ), + table_rows=table_rows, + table_footer=module.template_render_hook( + module, + template=template_table_footer, + action_delete_button=dom_element_delete_button + ) + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "manage_entities_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def options_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('manage_entities_widget/view_options.html') + template_options_toggle = module.templates.get_template('manage_entities_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template('manage_entities_widget/control_switch_options_view.html') + + options_toggle = module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + options_view_toggle=False, + steamid=dispatchers_steamid + ) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + widget_options=module.options + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "manage_entities_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def table_rows(*args, ** kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + method = kwargs.get("method", None) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + if method in ["upsert", "edit", "insert"]: + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + if current_view == "frontend": + template_table_rows = module.templates.get_template('manage_entities_widget/table_row.html') + + for entity_id, entity_dict in updated_values_dict.items(): + try: + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(entity_dict["dataset"]) + table_row_id = "entity_table_row_{}_{}".format( + sanitized_dataset, + str(entity_id) + ) + # Update entity_dict with sanitized dataset for template + entity_dict = entity_dict.copy() + entity_dict["dataset"] = sanitized_dataset + entity_dict["dataset_original"] = updated_values_dict[entity_id].get("dataset", "") + except KeyError: + table_row_id = "manage_entities_widget" + + selected_entity_entries = ( + module.dom.data + .get("module_game_environment", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(entity_id, {}) + .get("selected_by", []) + ) + + entity_entry_selected = False + if clientid in selected_entity_entries: + entity_entry_selected = True + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_game_environment", + dom_element_select_root=["selected_by"], + dom_element=entity_dict, + dom_element_entry_selected=entity_entry_selected, + dom_action_inactive="select_dom_element", + dom_action_active="deselect_dom_element" + ) + + table_row = module.template_render_hook( + module, + template=template_table_rows, + entity=entity_dict, + css_class=get_entity_table_row_css_class(entity_dict), + control_select_link=control_select_link + ) + + module.webserver.send_data_to_client_hook( + module, + payload=table_row, + data_type="table_row", + clients=[clientid], + target_element={ + "id": table_row_id, + "type": "tr", + "class": get_entity_table_row_css_class(entity_dict), + "selector": "body > main > div > div#manage_entities_widget > main > table > tbody" + } + ) + elif method == "remove": + entity_origin = updated_values_dict[2] + entity_id = updated_values_dict[3] + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_origin = module.dom_management.sanitize_for_html_id(entity_origin) + module.webserver.send_data_to_client_hook( + module, + data_type="remove_table_row", + clients="all", + target_element={ + "id": "entity_table_row_{}_{}".format( + sanitized_origin, + str(entity_id) + ) + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_widget(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + method = kwargs.get("method", None) + if method in ["update"]: + entity_dict = updated_values_dict + player_clients_to_update = list(module.webserver.connected_clients.keys()) + + for clientid in player_clients_to_update: + try: + current_view = module.get_current_view(clientid) + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(entity_dict.get("dataset", "")) + table_row_id = "entity_table_row_{}_{}".format( + sanitized_dataset, + str(entity_dict.get("id", None)) + ) + # Update entity_dict with sanitized dataset + original_dataset = entity_dict.get("dataset", "") + entity_dict = entity_dict.copy() + entity_dict["dataset"] = sanitized_dataset + entity_dict["dataset_original"] = original_dataset + + if current_view == "frontend": + module.webserver.send_data_to_client_hook( + module, + payload=entity_dict, + data_type="table_row_content", + clients="all", + method="update", + target_element={ + "id": table_row_id, + "parent_id": "manage_entities_widget", + "module": "game_environment", + "type": "tr", + "selector": "body > main > div > div#manage_entities_widget", + "class": get_entity_table_row_css_class(entity_dict), + } + ) + except AttributeError as error: + pass + except KeyError as error: + pass + + +def update_selection_status(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(updated_values_dict["dataset"]) + + module.dom_management.update_selection_status( + *args, **kwargs, + target_module=module, + dom_action_active="deselect_dom_element", + dom_action_inactive="select_dom_element", + dom_element_id={ + "id": "entity_table_row_{}_{}_control_select_link".format( + sanitized_dataset, + updated_values_dict["identifier"] + ) + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_delete_button_status(*args, **kwargs): + module = args[0] + + module.dom_management.update_delete_button_status( + *args, **kwargs, + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + target_module=module, + dom_action="delete_selected_dom_elements", + dom_element_id={ + "id": "entity_table_widget_action_delete_button" + } + ) + + +widget_meta = { + "description": "sends and updates a table of all currently known entities", + "main_widget": select_view, + "handlers": { + "module_game_environment/visibility/%steamid%/current_view": + select_view, + "module_game_environment/elements/%map_identifier%/%id%": + table_rows, + "module_game_environment/elements/%map_identifier%/%id%/pos": + update_widget, + "module_game_environment/elements/%map_identifier%/%id%/selected_by": + update_selection_status, + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/locations/__init__.py b/bot/modules/locations/__init__.py new file mode 100644 index 0000000..5999449 --- /dev/null +++ b/bot/modules/locations/__init__.py @@ -0,0 +1,252 @@ +from bot.module import Module +from bot import loaded_modules_dict +from time import time +import math + + +class Locations(Module): + dom_element_root = list + dom_element_select_root = list + default_max_locations = int + standard_location_shape = str + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:], + "dom_element_root": ["%dom_element_identifier%"], + "dom_element_select_root": ["%dom_element_identifier%", "selected_by"], + "default_max_locations": 3, + "standard_location_shape": "rectangular", + "run_observer_interval": 3, + "run_observer_interval_idle": 10 + }) + + setattr(self, "required_modules", [ + 'module_dom', + 'module_dom_management', + 'module_game_environment', + 'module_players', + 'module_telnet', + 'module_webserver' + ]) + + self.next_cycle = 0 + self.all_available_actions_dict = {} + self.all_available_widgets_dict = {} + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_locations" + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + + self.run_observer_interval = self.options.get( + "run_observer_interval", self.default_options.get("run_observer_interval", None) + ) + self.run_observer_interval_idle = self.options.get( + "run_observer_interval_idle", self.default_options.get("run_observer_interval_idle", None) + ) + self.dom_element_root = self.options.get( + "dom_element_root", self.default_options.get("dom_element_root", None) + ) + self.dom_element_select_root = self.options.get( + "dom_element_select_root", self.default_options.get("dom_element_select_root", None) + ) + self.default_max_locations = self.options.get( + "default_max_locations", self.default_options.get("default_max_locations", None) + ) + self.standard_location_shape = self.options.get( + "standard_location_shape", self.default_options.get("standard_location_shape", None) + ) + # endregion + + def start(self): + """ all modules have been loaded and initialized by now. we can bend the rules here.""" + Module.start(self) + # endregion + + def get_elements_by_type(self, location_type: str, var=None): + if var is None: + active_dataset = self.dom.data.get("module_game_environment", {}).get("active_dataset", None) + var = ( + self.dom.data + .get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + ) + if hasattr(var, 'items'): + for k, v in var.items(): + if k == "type": + if location_type in v: + yield var + if isinstance(v, dict): + for result in self.get_elements_by_type(location_type, v): + yield result + elif isinstance(v, list): + for d in v: + for result in self.get_elements_by_type(location_type, d): + yield result + + @staticmethod + def get_location_volume(location_dict): + """ + Calculate the 3D volume coordinates for a box-shaped location. + + Handles different map quadrants (SW, SE, NE, NW) with appropriate + coordinate adjustments for the game's coordinate system. + + Args: + location_dict: Dictionary containing shape, dimensions, and coordinates + + Returns: + Dictionary with pos_x, pos_y, pos_z, pos_x2, pos_y2, pos_z2 + or None if not a box shape + """ + shape = location_dict.get("shape", None) + if shape != "box": + return None + + dimensions = location_dict.get("dimensions", None) + coords = location_dict.get("coordinates", None) + + # Convert coordinates to integers + x = int(float(coords["x"])) + y = int(float(coords["y"])) + z = int(float(coords["z"])) + + width = float(dimensions["width"]) + height = float(dimensions["height"]) + length = float(dimensions["length"]) + + # Determine quadrant and calculate adjustments + is_west = x < 0 # West quadrants (SW, NW) + is_south = z < 0 # South quadrants (SW, SE) + + # Base coordinates + pos_x = x - 1 if is_west else x + pos_z = z - 1 + + # Z2 adjustment depends on quadrant + z2_offset = -2 if is_south else -1 + + return { + "pos_x": pos_x, + "pos_y": y, + "pos_z": pos_z, + "pos_x2": int(x - width), + "pos_y2": int(y + height - 1), + "pos_z2": int(z + length + z2_offset) + } + + @staticmethod + def _is_inside_sphere(player_pos, center, radius): + """Check if position is inside a 3D sphere.""" + distance = math.sqrt( + (player_pos['x'] - center['x']) ** 2 + + (player_pos['y'] - center['y']) ** 2 + + (player_pos['z'] - center['z']) ** 2 + ) + return distance <= radius + + @staticmethod + def _is_inside_circle(player_pos, center, radius): + """Check if position is inside a 2D circle (ignores Y axis).""" + distance = math.sqrt( + (player_pos['x'] - center['x']) ** 2 + + (player_pos['z'] - center['z']) ** 2 + ) + return distance <= radius + + @staticmethod + def _is_inside_box(player_pos, corner, dimensions): + """Check if position is inside a 3D box.""" + return all([ + player_pos['x'] - dimensions['width'] <= corner['x'] <= player_pos['x'] + dimensions['width'], + player_pos['y'] - dimensions['height'] <= corner['y'] <= player_pos['y'] + dimensions['height'], + player_pos['z'] - dimensions['length'] <= corner['z'] <= player_pos['z'] + dimensions['length'] + ]) + + @staticmethod + def _is_inside_rectangle(player_pos, corner, dimensions): + """Check if position is inside a 2D rectangle (ignores Y axis).""" + return all([ + player_pos['x'] - dimensions['width'] <= corner['x'] <= player_pos['x'] + dimensions['width'], + player_pos['z'] - dimensions['length'] <= corner['z'] <= player_pos['z'] + dimensions['length'] + ]) + + @staticmethod + def position_is_inside_boundary(position_dict=None, boundary_dict=None): + """ + Check if a position is inside a boundary shape. + + Supports multiple shape types: spherical, circle, box, rectangular. + + Args: + position_dict: Dictionary with 'pos' containing x, y, z coordinates + boundary_dict: Dictionary with 'shape', 'dimensions', 'coordinates' + + Returns: + bool: True if position is inside boundary, False otherwise + """ + if not all([position_dict, boundary_dict]): + return False + + shape = boundary_dict.get("shape") + dimensions = boundary_dict.get("dimensions") + coordinates = boundary_dict.get("coordinates") + + if not all([shape, dimensions, coordinates]): + return False + + # Extract player position + player_pos = { + 'x': float(position_dict.get("pos", {}).get("x", 0)), + 'y': float(position_dict.get("pos", {}).get("y", 0)), + 'z': float(position_dict.get("pos", {}).get("z", 0)) + } + + # Extract boundary center/corner coordinates + boundary_coords = { + 'x': float(coordinates.get("x", 0)), + 'y': float(coordinates.get("y", 0)), + 'z': float(coordinates.get("z", 0)) + } + + # Check shape type and delegate to appropriate helper + if shape == "spherical": + radius = float(dimensions.get("radius", 0)) + return Locations._is_inside_sphere(player_pos, boundary_coords, radius) + + elif shape == "circle": + radius = float(dimensions.get("radius", 0)) + return Locations._is_inside_circle(player_pos, boundary_coords, radius) + + elif shape == "box": + dims = { + 'width': float(dimensions.get("width", 0)), + 'height': float(dimensions.get("height", 0)), + 'length': float(dimensions.get("length", 0)) + } + return Locations._is_inside_box(player_pos, boundary_coords, dims) + + elif shape == "rectangular": + dims = { + 'width': float(dimensions.get("width", 0)), + 'length': float(dimensions.get("length", 0)) + } + return Locations._is_inside_rectangle(player_pos, boundary_coords, dims) + + return False + + def run(self): + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + + +loaded_modules_dict[Locations().get_module_identifier()] = Locations() diff --git a/bot/modules/locations/actions/bc-export.py b/bot/modules/locations/actions/bc-export.py new file mode 100644 index 0000000..e7ec507 --- /dev/null +++ b/bot/modules/locations/actions/bc-export.py @@ -0,0 +1,55 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + location_identifier = event_data[1].get("location_identifier") + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(dispatchers_steamid, {}) + .get(location_identifier, None) + ) + + coordinates = module.get_location_volume(location_dict) + if coordinates is not None: + command = ( + "bc-export {location_to_be_exported} {pos_x} {pos_y} {pos_z} {pos_x2} {pos_y2} {pos_z2}" + ).format( + location_to_be_exported=location_dict.get("identifier"), + **coordinates + ) + + module.telnet.add_telnet_command_to_queue(command) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Will export everything inside the locations Volume. Will only work for 'box' locations", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/bc-import.py b/bot/modules/locations/actions/bc-import.py new file mode 100644 index 0000000..a95c0b4 --- /dev/null +++ b/bot/modules/locations/actions/bc-import.py @@ -0,0 +1,97 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def fix_coordinates_for_bc_import(location_dict: dict, coordinates: dict, player_dict=None) -> bool: + shape = location_dict.get("shape", None) + dimensions = location_dict.get("dimensions", None) + location_coordinates = location_dict.get("coordinates", None) + + if shape == "box": + if player_dict is None: + if int(float(location_coordinates["x"])) < 0: # W Half + coordinates["pos_x"] = int(float(coordinates["pos_x"]) - float(dimensions["width"]) + 1) + if int(float(location_coordinates["x"])) >= 0: # E Half + coordinates["pos_x"] = int(float(coordinates["pos_x"]) - float(dimensions["width"])) + else: + if int(float(player_dict.get("pos", {}).get("x"))) < 0: # W Half + coordinates["pos_x"] = int(float(player_dict.get("pos", {}).get("x")) - float(dimensions["width"]) - 1) + if int(float(player_dict.get("pos", {}).get("x"))) >= 0: # E Half + coordinates["pos_x"] = int(float(player_dict.get("pos", {}).get("x")) - float(dimensions["width"])) + + coordinates["pos_y"] = int(float(player_dict.get("pos", {}).get("y"))) + + if int(float(player_dict.get("pos", {}).get("z"))) < 0: # S Half + coordinates["pos_z"] = int(float(player_dict.get("pos", {}).get("z")) - 1) + if int(float(player_dict.get("pos", {}).get("z"))) >= 0: # N Half + coordinates["pos_z"] = int(float(player_dict.get("pos", {}).get("z"))) + + return True + else: + return False + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + location_identifier = event_data[1].get("location_identifier") + spawn_in_place = event_data[1].get("spawn_in_place") + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(dispatchers_steamid, {}) + .get(location_identifier, None) + ) + + coordinates = module.get_location_volume(location_dict) + + if coordinates is not None: + if spawn_in_place: + player_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(dispatchers_steamid, {}) + ) + fix_coordinates_for_bc_import(location_dict, coordinates, player_dict) + else: + fix_coordinates_for_bc_import(location_dict, coordinates) + + command = ( + "bc-import {location_to_be_imported} {pos_x} {pos_y} {pos_z}" + ).format( + location_to_be_imported=location_dict.get("identifier"), + **coordinates + ) + + module.telnet.add_telnet_command_to_queue(command) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Imports a saved prefab. Needs to have a location first!", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/edit_location.py b/bot/modules/locations/actions/edit_location.py new file mode 100644 index 0000000..318bdc4 --- /dev/null +++ b/bot/modules/locations/actions/edit_location.py @@ -0,0 +1,82 @@ +from bot import loaded_modules_dict +from os import path, pardir +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + location_identifier = event_data[1].get("location_identifier", None) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + event_data[1]["action_identifier"] = action_name + event_data[1]["fail_reason"] = [] + + location_owner = event_data[1].get("location_owner", dispatchers_steamid) + location_name = event_data[1].get("location_name", None) + if location_identifier is None or location_identifier == "": + location_identifier = ''.join(e for e in location_name if e.isalnum()) + + location_shape = event_data[1].get("location_shape", module.default_options.get("standard_location_shape", None)) + location_types = event_data[1].get("location_type", []) + location_coordinates = event_data[1].get("location_coordinates", {}) + location_teleport_entry = event_data[1].get("location_teleport_entry", {}) + location_dimensions = event_data[1].get("location_dimensions", {}) + location_enabled = event_data[1].get("is_enabled", False) + last_changed = event_data[1].get("last_changed", False) + + if all([ + location_name is not None and len(location_name) >= 3, + location_identifier is not None, + location_shape is not None, + active_dataset is not None, + location_owner is not None + ]): + module.dom.data.upsert({ + module.get_module_identifier(): { + "elements": { + active_dataset: { + str(location_owner): { + location_identifier: { + "name": location_name, + "identifier": location_identifier, + "dataset": active_dataset, + "shape": location_shape, + "type": location_types, + "coordinates": location_coordinates, + "teleport_entry": location_teleport_entry, + "dimensions": location_dimensions, + "owner": str(location_owner), + "is_enabled": location_enabled, + "selected_by": [], + "last_changed": last_changed + } + } + } + } + } + }, dispatchers_steamid=dispatchers_steamid, max_callback_level=4) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + event_data[1]["fail_reason"].append("not all conditions met!") + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages location entries", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/onslaught.py b/bot/modules/locations/actions/onslaught.py new file mode 100644 index 0000000..842dd5a --- /dev/null +++ b/bot/modules/locations/actions/onslaught.py @@ -0,0 +1,48 @@ +from bot import loaded_modules_dict +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + location_identifier = event_data[1].get("location_identifier", None) + + if action == "start onslaught": + event_data = ['say_to_player', { + 'steamid': dispatchers_steamid, + 'message': '[66FF66]Onslaught[-][FFFFFF] Started for location [66FF66]{}[-]'.format(location_identifier) + }] + module.trigger_action_hook(module.players, event_data=event_data) + elif action == "stop onslaught": + event_data = ['say_to_player', { + 'steamid': dispatchers_steamid, + 'message': '[66FF66]Onslaught[-][FFFFFF] in location [66FF66]{}[-] Ended[-]'.format(location_identifier) + }] + module.trigger_action_hook(module.players, event_data=event_data) + + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages the onslaught event on dedicated locations", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/teleport_to_coordinates.py b/bot/modules/locations/actions/teleport_to_coordinates.py new file mode 100644 index 0000000..d75ca2d --- /dev/null +++ b/bot/modules/locations/actions/teleport_to_coordinates.py @@ -0,0 +1,42 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + location_coordinates = event_data[1].get("location_coordinates", {}) + player_steamid = event_data[1].get("steamid", dispatchers_steamid) + if location_coordinates: + module.trigger_action_hook( + module.players, event_data=["teleport_player", { + "steamid": player_steamid, + "coordinates": location_coordinates + }]) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Teleports a player to a set of coordinates", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/toggle_enabled_flag.py b/bot/modules/locations/actions/toggle_enabled_flag.py new file mode 100644 index 0000000..0bfa9f6 --- /dev/null +++ b/bot/modules/locations/actions/toggle_enabled_flag.py @@ -0,0 +1,59 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + action = event_data[1].get("action", None) + location_origin = event_data[1].get("dom_element_origin", None) + location_owner = event_data[1].get("dom_element_owner", None) + location_identifier = event_data[1].get("dom_element_identifier", None) + + if all([ + action is not None + ]): + if action == "enable_location_entry" or action == "disable_location_entry": + element_is_enabled = action == "enable_location_entry" + + module.dom.data.upsert({ + "module_locations": { + "elements": { + location_origin: { + location_owner: { + location_identifier: { + "is_enabled": element_is_enabled + } + } + } + } + } + }, dispatchers_steamid=dispatchers_steamid, min_callback_level=4) + + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Sets or removes the enabled flag of a location", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/actions/toggle_locations_widget_view.py b/bot/modules/locations/actions/toggle_locations_widget_view.py new file mode 100644 index 0000000..110e785 --- /dev/null +++ b/bot/modules/locations/actions/toggle_locations_widget_view.py @@ -0,0 +1,72 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + action = event_data[1].get("action", None) + location_owner = event_data[1].get("dom_element_owner", None) + location_identifier = event_data[1].get("dom_element_identifier", None) + location_origin = event_data[1].get("dom_element_origin", None) + + # Support for prefilled coordinates from map + prefill_x = event_data[1].get("prefill_x", None) + prefill_y = event_data[1].get("prefill_y", None) + prefill_z = event_data[1].get("prefill_z", None) + + if action == "show_options": + current_view = "options" + elif action == "show_frontend": + current_view = "frontend" + elif action == "show_create_new": + current_view = "create_new" + elif action == "edit_location_entry": + current_view = "edit_location_entry" + elif action == "show_special_locations": + current_view = "special_locations" + elif action == "show_map": + current_view = "map" + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + view_data = { + "current_view": current_view, + "location_owner": location_owner, + "location_identifier": location_identifier, + "location_origin": location_origin + } + + # Add prefill data if creating new location + if current_view == "create_new" and any([prefill_x, prefill_y, prefill_z]): + view_data["prefill_coordinates"] = { + "x": prefill_x, + "y": prefill_y, + "z": prefill_z + } + + module.set_current_view(dispatchers_steamid, view_data) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages location stuff", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/locations/commands/add_location.py b/bot/modules/locations/commands/add_location.py new file mode 100644 index 0000000..cccfe0e --- /dev/null +++ b/bot/modules/locations/commands/add_location.py @@ -0,0 +1,64 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + steamid = regex_result.group("player_steamid") + + result = re.match(r"^.*add\slocation\s(?P.*)", command) + if result: + location_name = result.group("location_name") + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + player_dict = module.dom.data.get("module_players", {}).get("elements", {}).get(active_dataset, {}).get(steamid, {}) + if len(player_dict) >= 1 and result: + event_data = ['edit_location', { + 'location_coordinates': { + "x": player_dict["pos"]["x"], + "y": player_dict["pos"]["y"], + "z": player_dict["pos"]["z"] + }, + 'location_name': location_name, + 'action': 'create_new', + 'last_changed': module.game_environment.get_last_recorded_gametime_string() + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=steamid) + + +triggers = { + "add location": r"\'(?P.*)\'\:\s(?P\/add location.*)" +} + +trigger_meta = { + "description": "catches location commands from the players chat and then adds them to the database", + "main_function": main_function, + "triggers": [ + { + "identifier": "add location (Alloc)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["add location"] + ), + "callback": main_function + }, + { + "identifier": "add location (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["add location"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/export_location.py b/bot/modules/locations/commands/export_location.py new file mode 100644 index 0000000..3e281e4 --- /dev/null +++ b/bot/modules/locations/commands/export_location.py @@ -0,0 +1,77 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + player_steamid = regex_result.group("player_steamid") + + location_dict = None + result = re.match(r"^.*export\slocation\s(?P.*)(?:\s)?(?P.*)?", command) + if result: + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + location_identifier = result.group("location_identifier") + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + .get(location_identifier, None) + ) + location_name = location_dict.get("name") + else: + location_name = "None Provided" + location_identifier = "None" + + if location_dict is not None: + event_data = ['bc-export', { + "location_identifier": location_identifier + }] + module.trigger_action_hook(origin_module.locations, event_data=event_data, dispatchers_steamid=player_steamid) + + else: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FFFFFF]Could not find [66FF66]{location_name} ({location_identifier})[-]'.format( + location_name=location_name, + location_identifier=location_identifier + ) + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + + +triggers = { + "export location": r"\'(?P.*)\'\:\s(?P\/export location.*)" +} + +trigger_meta = { + "description": "will issue the BCM mods bc-export command on the specified location", + "main_function": main_function, + "triggers": [ + { + "identifier": "export location", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["export location"] + ), + "callback": main_function + }, + { + "identifier": "export location", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["export location"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/import_location.py b/bot/modules/locations/commands/import_location.py new file mode 100644 index 0000000..434328a --- /dev/null +++ b/bot/modules/locations/commands/import_location.py @@ -0,0 +1,78 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + player_steamid = regex_result.group("player_steamid") + + location_dict = None + spawn_in_place = False + result = re.match(r"^.*import\slocation\s(?P\S+)(?:\s)?(?Phere)?", command) + if result: + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + location_identifier = result.group("location_identifier") + location_dict = ( + module.dom.data + .get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + .get(location_identifier, None) + ) + spawn_in_place = result.group("spawn_in_place") == "here" + + if location_dict is not None: + event_data = ['bc-import', { + "location_identifier": location_identifier, + "spawn_in_place": spawn_in_place + }] + module.trigger_action_hook(origin_module.locations, event_data=event_data, dispatchers_steamid=player_steamid) + + else: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FFFFFF]Could not find [66FF66]{location_name} ({location_identifier})[-]'.format( + location_name="None Provided", + location_identifier="None" + ) + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + + +triggers = { + "import location": r"\'(?P.*)\'\:\s(?P\/import location.*)" +} + + +trigger_meta = { + "description": "will issue the BCM mods bc-import command on the specified location", + "main_function": main_function, + "triggers": [ + { + "identifier": "import location (Allocs)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["import location"] + ), + "callback": main_function + }, + { + "identifier": "import location (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["import location"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/send_me_home.py b/bot/modules/locations/commands/send_me_home.py new file mode 100644 index 0000000..2413301 --- /dev/null +++ b/bot/modules/locations/commands/send_me_home.py @@ -0,0 +1,59 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + player_steamid = regex_result.group("player_steamid") + + found_home = False + location_dict = {} + for home in origin_module.get_elements_by_type("is_home"): + if home.get("owner") == player_steamid: + location_dict = home + found_home = True + + if found_home is True and len(location_dict) >= 1: + event_data = ['teleport_to_coordinates', { + 'location_coordinates': { + "x": location_dict.get("teleport_entry", {}).get("x", location_dict["coordinates"]["x"]), + "y": location_dict.get("teleport_entry", {}).get("y", location_dict["coordinates"]["y"]), + "z": location_dict.get("teleport_entry", {}).get("z", location_dict["coordinates"]["z"]) + } + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=player_steamid) + + +triggers = { + "send me home": r"\'(?P.*)\'\:\s(?P\/send\sme\shome)" +} + +trigger_meta = { + "description": "sends the player to his home, if available", + "main_function": main_function, + "triggers": [ + { + "identifier": "send me home", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["send me home"] + ), + "callback": main_function + }, + { + "identifier": "send me home", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["send me home"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/send_me_to_location.py b/bot/modules/locations/commands/send_me_to_location.py new file mode 100644 index 0000000..3277344 --- /dev/null +++ b/bot/modules/locations/commands/send_me_to_location.py @@ -0,0 +1,102 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + player_steamid = regex_result.group("player_steamid") + + result = re.match(r"^.*send\sme\sto\slocation\s(?P.*)", command) + if result: + location_identifier = result.group("location_identifier") + else: + return + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + .get(location_identifier, {}) + ) + + player_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + + if len(player_dict) >= 1 and len(location_dict) >= 1: + teleport_entry = location_dict.get("teleport_entry", {}) + teleport_entry_x = teleport_entry.get("x", None) + teleport_entry_y = teleport_entry.get("y", None) + teleport_entry_z = teleport_entry.get("z", None) + + if any([ + teleport_entry_x is None, + teleport_entry_y is None, + teleport_entry_z is None, + all([ + int(teleport_entry_x) == 0, + int(teleport_entry_y) == 0, + int(teleport_entry_z) == 0, + ]) + ]): + location_coordinates = { + "x": location_dict["coordinates"]["x"], + "y": location_dict["coordinates"]["y"], + "z": location_dict["coordinates"]["z"] + } + else: + location_coordinates = { + "x": teleport_entry_x, + "y": teleport_entry_y, + "z": teleport_entry_z + } + + event_data = ['teleport_to_coordinates', { + 'location_coordinates': location_coordinates + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=player_steamid) + + +triggers = { + "send me to location": r"\'(?P.*)\'\:\s(?P\/send\sme\sto\slocation.*)" +} + +trigger_meta = { + "description": ( + "sends player to the location of their choosing, will use the teleport_entry coordinates if available" + ), + "main_function": main_function, + "triggers": [ + { + "identifier": "add location (Alloc)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["send me to location"] + ), + "callback": main_function + }, + { + "identifier": "add location (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["send me to location"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/start_onslaught.py b/bot/modules/locations/commands/start_onslaught.py new file mode 100644 index 0000000..0fd6bf6 --- /dev/null +++ b/bot/modules/locations/commands/start_onslaught.py @@ -0,0 +1,116 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + steamid = regex_result.group("player_steamid") + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + player_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(steamid, {}) + ) + + if len(player_dict) < 1: + return False + + result = re.match(r"^.*st.*\sonslaught\s(?P.*)", command) + if result: + onslaught_options = result.group("onslaught_options") + else: + """ no options provided + might later chose the location one is standing in and owns, or let some other stuff happen + """ + onslaught_options = None + + if command.startswith("/start onslaught"): + """ check if the player is inside a location which allows onslaught to be enabled """ + # let's iterate through all suitable locations + for onslaught_location in origin_module.get_elements_by_type("is_onslaught"): + # only proceed with the player is inside a dedicated location + if any([ + onslaught_options in ["everywhere", onslaught_location["identifier"]], + origin_module.position_is_inside_boundary(player_dict, onslaught_location) + ]): + # fire onslaught in all selected locations + event_data = ['onslaught', { + 'onslaught_options': onslaught_options, + 'location_owner': onslaught_location['owner'], + 'location_identifier': onslaught_location['identifier'], + 'action': 'start onslaught' + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=steamid) + + elif command.startswith("/stop onslaught"): + for onslaught_location in origin_module.get_elements_by_type("is_onslaught"): + # only proceed with the player is inside a dedicated location + if any([ + onslaught_options in ["everywhere", onslaught_location["identifier"]], + origin_module.position_is_inside_boundary(player_dict, onslaught_location) + ]): + # fire onslaught in all selected locations + event_data = ['onslaught', { + 'location_owner': onslaught_location['owner'], + 'location_identifier': onslaught_location['identifier'], + 'action': 'stop onslaught' + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=steamid) + + +triggers = { + "start onslaught": r"\'(?P.*)\'\:\s(?P\/start\sonslaught.*)", + "stop onslaught": r"\'(?P.*)\'\:\s(?P\/stop\sonslaught.*)" +} + +trigger_meta = { + "description": "will start the onslaught event in a specified location", + "main_function": main_function, + "triggers": [ + { + "identifier": "start onslaught (Alloc)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["start onslaught"] + ), + "callback": main_function + }, + { + "identifier": "stop onslaught (Alloc)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["stop onslaught"] + ), + "callback": main_function + }, + { + "identifier": "start onslaught (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["start onslaught"] + ), + "callback": main_function + }, + { + "identifier": "stop onslaught (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["stop onslaught"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/take_me_to_my_grave.py b/bot/modules/locations/commands/take_me_to_my_grave.py new file mode 100644 index 0000000..4d3f575 --- /dev/null +++ b/bot/modules/locations/commands/take_me_to_my_grave.py @@ -0,0 +1,64 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + location_identifier = "PlaceofDeath" + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + steamid = regex_result.group("player_steamid") + + player_dict = module.dom.data.get("module_players", {}).get("elements", {}).get(active_dataset, {}).get(steamid, {}) + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(steamid, {}) + .get(location_identifier, {}) + ) + + if len(player_dict) >= 1 and len(location_dict) >= 1: + event_data = ['teleport_to_coordinates', { + 'location_coordinates': { + "x": location_dict["coordinates"]["x"], + "y": location_dict["coordinates"]["y"], + "z": location_dict["coordinates"]["z"] + } + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=steamid) + + +triggers = { + "take me to my grave": r"\'(?P.*)\'\:\s(?P\/take me to my grave)" +} + +trigger_meta = { + "description": "sends the player to his final resting place, if available", + "main_function": main_function, + "triggers": [ + { + "identifier": "take me to my grave (Allocs)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["take me to my grave"] + ), + "callback": main_function + }, + { + "identifier": "take me to my grave (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["take me to my grave"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/commands/where_am_i.py b/bot/modules/locations/commands/where_am_i.py new file mode 100644 index 0000000..a04bb7a --- /dev/null +++ b/bot/modules/locations/commands/where_am_i.py @@ -0,0 +1,100 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def find_by_key(data, target): + for key, value in data.items(): + if isinstance(value, dict): + yield from find_by_key(value, target) + elif key == target: + yield value + + +def main_function(origin_module, module, regex_result): + player_steamid = regex_result.group("player_steamid") + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + player_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + + all_locations_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + ) + + occupied_locations = [] + for locations_by_owner in all_locations_dict: + for location_identifier, location_dict in all_locations_dict[locations_by_owner].items(): + if origin_module.position_is_inside_boundary(player_dict, location_dict): + occupied_locations.append(location_dict) + + if len(occupied_locations) > 0: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FFFFFF]You are inside the following locations:[-]' + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + + for location_dict in occupied_locations: + location_owner_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(location_dict.get("owner"), {}) + ) + + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FFFFFF]{name} [66FF66]({owner})[-]'.format( + name=location_dict.get("name", "n/a"), + owner=location_owner_dict.get("name", "n/a") + ) + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + else: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FFFFFF]You do not seem to be in any designated location[-]' + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + + +triggers = { + "where am i": r"\'(?P.*)\'\:\s(?P\/where am i)" +} + +trigger_meta = { + "description": "prints out a list of locations a player currently occupies", + "main_function": main_function, + "triggers": [ + { + "identifier": "where am i (Allocs)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["where am i"] + ), + "callback": main_function + }, + { + "identifier": "where am i (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["where am i"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/templates/jinja2_macros.html b/bot/modules/locations/templates/jinja2_macros.html new file mode 100644 index 0000000..c25a7c7 --- /dev/null +++ b/bot/modules/locations/templates/jinja2_macros.html @@ -0,0 +1,56 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} +{%- if deactivate_event != none and activate_event != none -%} +{{ active_text }} +{%- elif deactivate_event != none and activate_event == none -%} +{{ active_text }} +{%- endif -%} +{%- else -%} +{%- if deactivate_event != none and activate_event != none -%} +{{ inactive_text }} +{%- elif deactivate_event != none and activate_event == none -%} +{{ active_text }} +{%- endif -%} +{%- endif -%} +{%- endmacro -%} + +{%- macro construct_view_menu(views, current_view, module_name, steamid, default_view='frontend') -%} +{# +Dynamically construct a navigation menu for widget views. + +Parameters: +views: Dict of view configurations +Example: { +'frontend': {'label_active': 'back', 'label_inactive': 'main', 'action': 'show_frontend'}, +'options': {'label_active': 'back', 'label_inactive': 'options', 'action': 'show_options'} +} +current_view: Current active view name (string) +module_name: Module name for socket.io event (e.g., 'locations') +steamid: User's steamid for action parameters +default_view: View to return to when deactivating (default: 'frontend') +#} +{%- for view_id, config in views.items() -%} +{%- if config.get('include_in_menu', True) -%} +{%- set is_active = (current_view == view_id) -%} +{%- set label_active = config.get('label_active', config.get('label', 'back')) -%} +{%- set label_inactive = config.get('label_inactive', config.get('label', view_id)) -%} +{%- set action = config.get('action', 'show_' ~ view_id) -%} +{%- set default_action = config.get('default_action', 'show_' ~ default_view) -%} + +
+ {{ construct_toggle_link( + is_active, + label_active, + ['widget_event', [module_name, ['toggle_' ~ module_name ~ '_widget_view', {'steamid': steamid, 'action': default_action}]]], + label_inactive, + ['widget_event', [module_name, ['toggle_' ~ module_name ~ '_widget_view', {'steamid': steamid, 'action': action}]]] + )}} +
+{%- endif -%} +{%- endfor -%} +{%- endmacro -%} diff --git a/bot/modules/locations/templates/manage_locations_widget/control_edit_link.html b/bot/modules/locations/templates/manage_locations_widget/control_edit_link.html new file mode 100644 index 0000000..7a4cdac --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_edit_link.html @@ -0,0 +1,12 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + +{{- construct_toggle_link( + True, + "edit", ['widget_event', ['locations', ['toggle_locations_widget_view', { + "dom_element_owner": location.owner, + "dom_element_identifier": location.identifier, + "dom_element_origin": location.dataset_original, + "action": "edit_location_entry" + }]]] +) -}} + \ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/control_enabled_link.html b/bot/modules/locations/templates/manage_locations_widget/control_enabled_link.html new file mode 100644 index 0000000..b55b378 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_enabled_link.html @@ -0,0 +1,18 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + +{{- construct_toggle_link( + location.is_enabled, + "enabled", ['widget_event', ['locations', ['toggle_enabled_flag', { + "dom_element_owner": location.owner, + "dom_element_identifier": location.identifier, + "dom_element_origin": location.dataset_original, + "action": "disable_location_entry" + }]]], + "enabled", ['widget_event', ['locations', ['toggle_enabled_flag', { + "dom_element_owner": location.owner, + "dom_element_identifier": location.identifier, + "dom_element_origin": location.dataset_original, + "action": "enable_location_entry" + }]]] +) -}} + \ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/control_player_location.html b/bot/modules/locations/templates/manage_locations_widget/control_player_location.html new file mode 100644 index 0000000..baccad9 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_player_location.html @@ -0,0 +1,10 @@ +
+ +
+ you are here: +
x: {{ pos_x }}
+
y: {{ pos_y }}
+
z: {{ pos_z }}
+
+
+
\ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/control_switch_create_new_view.html b/bot/modules/locations/templates/manage_locations_widget/control_switch_create_new_view.html new file mode 100644 index 0000000..9bac109 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_switch_create_new_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + create_new_view_toggle, + "create new location", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_create_new"}]]], + "back", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/locations/templates/manage_locations_widget/control_switch_map_view.html b/bot/modules/locations/templates/manage_locations_widget/control_switch_map_view.html new file mode 100644 index 0000000..378b141 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_switch_map_view.html @@ -0,0 +1,12 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + map_view_toggle, + "hide map", ['widget_event', ['locations', ['toggle_locations_widget_view', { + "action": 'show_frontend' + }]]], + "show map", ['widget_event', ['locations', ['toggle_locations_widget_view', { + "action": 'show_map' + }]]] + )}} +
diff --git a/bot/modules/locations/templates/manage_locations_widget/control_switch_options_view.html b/bot/modules/locations/templates/manage_locations_widget/control_switch_options_view.html new file mode 100644 index 0000000..f3438b9 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_switch_options_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + options_view_toggle, + "options", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_options"}]]], + "back", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/locations/templates/manage_locations_widget/control_switch_special_locations_view.html b/bot/modules/locations/templates/manage_locations_widget/control_switch_special_locations_view.html new file mode 100644 index 0000000..e33a36c --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_switch_special_locations_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + special_locations_view_toggle, + "special locations", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_special_locations"}]]], + "back", ['widget_event', ['locations', ['toggle_locations_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/locations/templates/manage_locations_widget/control_switch_view.html b/bot/modules/locations/templates/manage_locations_widget/control_switch_view.html new file mode 100644 index 0000000..3598afc --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_switch_view.html @@ -0,0 +1,7 @@ +
+ {{ control_switch_options_view }} + {{ control_switch_create_new_view }} + {{ control_switch_special_locations_view }} + {{ control_switch_map_view }} + {{ control_player_location_view }} +
\ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/control_view_menu.html b/bot/modules/locations/templates/manage_locations_widget/control_view_menu.html new file mode 100644 index 0000000..4d73eae --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/control_view_menu.html @@ -0,0 +1,11 @@ +{%- from 'jinja2_macros.html' import construct_view_menu with context -%} +
+ {{ construct_view_menu( + views=views, + current_view=current_view, + module_name='locations', + steamid=steamid, + default_view='frontend' + )}} + {{ control_player_location_view }} +
diff --git a/bot/modules/locations/templates/manage_locations_widget/table_footer.html b/bot/modules/locations/templates/manage_locations_widget/table_footer.html new file mode 100644 index 0000000..a5223b3 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/table_footer.html @@ -0,0 +1,5 @@ + + +
{{ action_delete_button }}
+ + diff --git a/bot/modules/locations/templates/manage_locations_widget/table_header.html b/bot/modules/locations/templates/manage_locations_widget/table_header.html new file mode 100644 index 0000000..a702b62 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/table_header.html @@ -0,0 +1,9 @@ + + * + actions + name + owner name + identifier + coordinates + last changed + diff --git a/bot/modules/locations/templates/manage_locations_widget/table_row.html b/bot/modules/locations/templates/manage_locations_widget/table_row.html new file mode 100644 index 0000000..a5af9f8 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/table_row.html @@ -0,0 +1,18 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{%- set location_is_enabled = location.is_enabled|default(false) -%} +{%- set location_entry_selected = false -%} + + + {{ control_select_link }} + + {{ control_edit_link }}{{ control_enabled_link }} + {{ location.name }} + {{ player_dict.name }} + {{ location.identifier }} + + {{ ((location | default({})).coordinates | default({}) ).x }} + {{ ((location | default({})).coordinates | default({}) ).y }} + {{ ((location | default({})).coordinates | default({}) ).z }} + + {{ location.last_changed }} + diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new.html new file mode 100644 index 0000000..096032e --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new.html @@ -0,0 +1,34 @@ +{% set is_edit = location_to_edit_dict | length > 1 %} +
+
+ Locations +
+
+ +
+ + + + + + + + + + + +
Create a new location!
+ {% with location_dict=location_to_edit_dict, is_edit=is_edit %} + {% include "manage_locations_widget/view_create_new_set_name.html" %} + {% include "manage_locations_widget/view_create_new_identifier.html" %} + {% include "manage_locations_widget/view_create_new_select_shape.html" %} + {% include "manage_locations_widget/view_create_new_set_dimensions.html" %} + {% include "manage_locations_widget/view_create_new_set_coordinates.html" %} + {% include "manage_locations_widget/view_create_new_set_teleport_coordinates.html" %} + {% include "manage_locations_widget/view_create_new_select_type.html" %} + {% include "manage_locations_widget/view_create_new_control_send_data.html" %} + {% endwith %} +
+
\ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_control_send_data.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_control_send_data.html new file mode 100644 index 0000000..fa61086 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_control_send_data.html @@ -0,0 +1,57 @@ +{% set action = "edit" if is_edit else "create_new" %} + + +save diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_identifier.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_identifier.html new file mode 100644 index 0000000..dc99521 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_identifier.html @@ -0,0 +1,35 @@ + + + + + + + + + + + +
+
{%- if not is_edit %} + {% endif %} + +
+
+
+ This is the Identifier of your location
+ It is used to handle the location with in-game chat commands
+ It has to be unique for each users locations.
+ This setting can not be changed later on!
+
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_shape.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_shape.html new file mode 100644 index 0000000..0f267fa --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_shape.html @@ -0,0 +1,83 @@ +{% set checked_string = ' checked="checked"' %} +{% set location_shape_not_found = true %} +{% if location_dict["shape"] == "circle" %} + {% set location_shape_circle_checked = checked_string %} + {% set location_shape_not_found = false %} +{% endif %} +{% if location_dict["shape"] == "spherical" %} + {% set location_shape_spherical_checked = checked_string %} + {% set location_shape_not_found = false %} +{% endif %} +{% if location_dict["shape"] == "rectangular" %} + {% set location_shape_rectangular_checked = checked_string %} + {% set location_shape_not_found = false %} +{% endif %} +{% if location_dict["shape"] == "box" %} + {% set location_shape_box_checked = checked_string %} + {% set location_shape_not_found = false %} +{% endif %} +{% if location_shape_not_found %} + {% set location_shape_rectangular_checked = checked_string %} +{% endif %} + + + + + + + + + + + + + + + + + + + + + +
+ Choose the shape of your location +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ locations can have several different shapes.
+ you can have either a square (default, just like the LCB) or a round base, depending on your build-style
+ You can also limit the height of your locations, so you can build, for example, + an underground base without affecting the ground above at all.
+ It is also useful for building multi-story builds with different locations per level, + or even rooms on the same one.
+ This setting can be changed later on, at any time! +
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_type.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_type.html new file mode 100644 index 0000000..b66c249 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_select_type.html @@ -0,0 +1,106 @@ +{% set checked_string = ' checked="checked"' %} +{% set location_type_not_found = true %} +{% if "is_village" in location_dict["type"] %} + {% set location_type_village_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if "is_screamerfree" in location_dict["type"] %} + {% set location_type_screamerfree_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if "is_home" in location_dict["type"] %} + {% set location_type_home_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if "is_lobby" in location_dict["type"] %} + {% set location_type_lobby_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if "is_onslaught" in location_dict["type"] %} + {% set location_type_onslaught_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if "is_hunting_resort" in location_dict["type"] %} + {% set location_type_hunting_resort_checked = checked_string %} + {% set location_type_not_found = false %} +{% endif %} +{% if location_type_not_found %}{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Choose the type of your location +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ locations can have a bunch of types
+ Types will add to one another, so you could create a home-lobby that is screamer-free and also it's own village :)
+ uncheck all to make it a simple location without any features attached. What for you ask?
+ Well, you could make your Builds available via locations, then people might find them easier. +
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_coordinates.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_coordinates.html new file mode 100644 index 0000000..e86f652 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_coordinates.html @@ -0,0 +1,55 @@ +{% set coordinates = location_dict.coordinates|default({}) %} +{% set pos_x = coordinates.x|default("0") %} +{% set pos_y = coordinates.y|default("0") %} +{% set pos_z = coordinates.z|default("0") %} + + + + + + + + + + + + + + + + + + + + +
+ Choose the coordinates of your location +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ Sets the coordinates of your location
+ This will be the S/E corner of your location area. For round shapes, imagine a rectangle wrapped around + it and use it's S/E corner +
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_dimensions.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_dimensions.html new file mode 100644 index 0000000..b32c80e --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_dimensions.html @@ -0,0 +1,89 @@ +{% set dimensions = location_dict.dimensions|default({}) %} +{% set location_dimensions_radius_value = dimensions.radius|default("0") %} +{% set location_dimensions_width_value = dimensions.width|default("0") %} +{% set location_dimensions_length_value = dimensions.length|default("0") %} +{% set location_dimensions_height_value = dimensions.height|default("0") %} + + + + + + + + + + + + + + + + +
+ Choose the size and dimensions of your location +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ Sets the Dimensions of your location
+ Depending on it's shape, it's either by radius or by defining up to three connecting side-lengths
+ The radius is set from the position the player is standing on, rectangular shapes start from + the S/E corner going N/W and UP.
+
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_name.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_name.html new file mode 100644 index 0000000..ec5cc51 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_name.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + +
+ Choose the name of your location +
+
+ +
+
+
+ Sets the name of your location
+ this is only for visual purposes, it has no bearing on it's function
+ This setting can be changed later on, at any time! +
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_teleport_coordinates.html b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_teleport_coordinates.html new file mode 100644 index 0000000..ac200d8 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_create_new_set_teleport_coordinates.html @@ -0,0 +1,54 @@ +{% set teleport_entry = location_dict.teleport_entry|default({}) %} +{% set pos_x = teleport_entry.x|default("0") %} +{% set pos_y = teleport_entry.y|default("0") %} +{% set pos_z = teleport_entry.z|default("0") %} + + + + + + + + + + + + + + + + + + + + +
+ Choose the teleport-entry for this location +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ Sets the teleport-entry point for your location
+ Players using the bots travel commands will emerge in this spot and not in the default S/E corner +
+
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_frontend.html b/bot/modules/locations/templates/manage_locations_widget/view_frontend.html new file mode 100644 index 0000000..63a3920 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_frontend.html @@ -0,0 +1,29 @@ +
+
+ Locations +
+
+ +
+ + + + {{ table_header }} + + + {{ table_rows }} + + + {{ table_footer }} + +
+ adapt +
+
+ +
+
\ No newline at end of file diff --git a/bot/modules/locations/templates/manage_locations_widget/view_map.html b/bot/modules/locations/templates/manage_locations_widget/view_map.html new file mode 100644 index 0000000..18d5e03 --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_map.html @@ -0,0 +1,474 @@ + + + + + +
+
+ Locations Map +
+
+ +
+
+ + + + + +
diff --git a/bot/modules/locations/templates/manage_locations_widget/view_options.html b/bot/modules/locations/templates/manage_locations_widget/view_options.html new file mode 100644 index 0000000..14b87ab --- /dev/null +++ b/bot/modules/locations/templates/manage_locations_widget/view_options.html @@ -0,0 +1,27 @@ +
+
+ Locations +
+
+ +
+ + + + + + + + + + + {% for key, value in widget_options.items() %} + + + + {% endfor %} + +
location widget options
location-options
{{key}}{{value}}
+
\ No newline at end of file diff --git a/bot/modules/locations/templates/webmap/location_actions.html b/bot/modules/locations/templates/webmap/location_actions.html new file mode 100644 index 0000000..03ff041 --- /dev/null +++ b/bot/modules/locations/templates/webmap/location_actions.html @@ -0,0 +1,172 @@ +// ======================================== +// Location Popup Actions +// ======================================== + +// Edit location from map popup +window.editLocationFromMap = function(dataset, owner, identifier) { + // This follows the exact same pattern as control_edit_link.html + window.socket.emit( + 'widget_event', + ['locations', + ['toggle_locations_widget_view', { + 'dom_element_owner': owner, + 'dom_element_identifier': identifier, + 'dom_element_origin': dataset, + 'action': 'edit_location_entry' + }]] + ); + console.log('[MAP] Opening edit view for location:', identifier); +}; + +// Toggle enabled status from map popup +window.toggleLocationEnabled = function(dataset, owner, identifier, isChecked) { + // This follows the exact same pattern as control_enabled_link.html + const action = isChecked ? 'enable_location_entry' : 'disable_location_entry'; + + window.socket.emit( + 'widget_event', + ['locations', + ['toggle_enabled_flag', { + 'dom_element_owner': owner, + 'dom_element_identifier': identifier, + 'dom_element_origin': dataset, + 'action': action + }]] + ); + console.log('[MAP] Toggling location enabled status:', identifier, 'to', isChecked); +}; + +// Move location to new position (relative teleport) +window.moveLocationFromMap = function(locationId) { + const loc = locations[locationId]; + if (!loc) { + console.error('[MAP] Location not found:', locationId); + return; + } + + // Close any open popups + map.closePopup(); + + // Create info message + const infoDiv = L.DomUtil.create('div', 'coordinates-display'); + infoDiv.style.bottom = '50px'; + infoDiv.style.background = 'rgba(102, 204, 255, 0.95)'; + infoDiv.style.borderColor = 'var(--lcars-anakiwa)'; + infoDiv.style.color = '#000'; + infoDiv.style.fontWeight = 'bold'; + infoDiv.innerHTML = '📍 Click new location position on map'; + document.getElementById('map').appendChild(infoDiv); + + // Change cursor + map.getContainer().style.cursor = 'crosshair'; + + // Wait for click + map.once('click', function(e) { + const newCoords = e.latlng; + const newX = Math.round(newCoords.lat); + const newZ = Math.round(newCoords.lng); + + // Calculate offset + const offsetX = newX - loc.coordinates.x; + const offsetZ = newZ - loc.coordinates.z; + + // Move teleport_entry relatively if it exists + let newTeleportEntry = loc.teleport_entry || {}; + if (newTeleportEntry.x !== undefined && newTeleportEntry.y !== undefined && newTeleportEntry.z !== undefined) { + newTeleportEntry = { + x: (parseFloat(newTeleportEntry.x) || 0) + offsetX, + y: parseFloat(newTeleportEntry.y) || 0, // Y stays same + z: (parseFloat(newTeleportEntry.z) || 0) + offsetZ + }; + } + + // Call edit_location with ALL fields + window.socket.emit( + 'widget_event', + ['locations', + ['edit_location', { + 'location_identifier': loc.identifier, + 'location_name': loc.name, + 'location_shape': loc.shape, + 'location_type': loc.type || [], + 'location_coordinates': { + 'x': newX, + 'y': loc.coordinates.y, + 'z': newZ + }, + 'location_teleport_entry': newTeleportEntry, + 'location_dimensions': loc.dimensions || {}, + 'location_owner': loc.owner, + 'is_enabled': loc.is_enabled + }]] + ); + + console.log('[MAP] Moved location to:', newX, newZ, 'Offset:', offsetX, offsetZ); + + // Cleanup + map.getContainer().style.cursor = ''; + document.getElementById('map').removeChild(infoDiv); + }); +}; + +// Set teleport coordinates +window.setTeleportFromMap = function(locationId) { + const loc = locations[locationId]; + if (!loc) { + console.error('[MAP] Location not found:', locationId); + return; + } + + // Close any open popups + map.closePopup(); + + // Create info message + const infoDiv = L.DomUtil.create('div', 'coordinates-display'); + infoDiv.style.bottom = '50px'; + infoDiv.style.background = 'rgba(255, 153, 0, 0.95)'; + infoDiv.style.borderColor = 'var(--lcars-golden-tanoi)'; + infoDiv.style.color = '#000'; + infoDiv.style.fontWeight = 'bold'; + infoDiv.innerHTML = '🎯 Click teleport destination on map'; + document.getElementById('map').appendChild(infoDiv); + + // Change cursor + map.getContainer().style.cursor = 'crosshair'; + + // Wait for click + map.once('click', function(e) { + const teleportCoords = e.latlng; + const tpX = Math.round(teleportCoords.lat); + const tpZ = Math.round(teleportCoords.lng); + + // Use current Y coordinate or default to location Y + const tpY = (loc.teleport_entry && loc.teleport_entry.y) || loc.coordinates.y; + + // Call edit_location with ALL fields + window.socket.emit( + 'widget_event', + ['locations', + ['edit_location', { + 'location_identifier': loc.identifier, + 'location_name': loc.name, + 'location_shape': loc.shape, + 'location_type': loc.type || [], + 'location_coordinates': loc.coordinates, + 'location_teleport_entry': { + 'x': tpX, + 'y': tpY, + 'z': tpZ + }, + 'location_dimensions': loc.dimensions || {}, + 'location_owner': loc.owner, + 'is_enabled': loc.is_enabled + }]] + ); + + console.log('[MAP] Set teleport to:', tpX, tpY, tpZ); + + // Cleanup + map.getContainer().style.cursor = ''; + document.getElementById('map').removeChild(infoDiv); + }); +}; diff --git a/bot/modules/locations/templates/webmap/location_shapes.html b/bot/modules/locations/templates/webmap/location_shapes.html new file mode 100644 index 0000000..9d603aa --- /dev/null +++ b/bot/modules/locations/templates/webmap/location_shapes.html @@ -0,0 +1,143 @@ +// Helper function to create location shape based on type +function createLocationShape(locationId, loc) { + const coords = loc.coordinates; + const centerLatLng = [coords.x, coords.z]; // 7D2D coordinates (x, z) + const dims = loc.dimensions || {}; + const shape = loc.shape || 'circle'; + const isEnabled = loc.is_enabled; + const is3D = (shape === 'box' || shape === 'spherical'); + + // Color scheme + const fillColor = isEnabled ? '#ff9900' : '#666666'; + const strokeColor = isEnabled ? '#ffcc00' : '#999999'; + const fillOpacity = isEnabled ? 0.3 : 0.15; + + let leafletShape; + + if (shape === 'circle') { + const radius = parseFloat(dims.radius || 10); + leafletShape = L.circle(centerLatLng, { + radius: radius, + fillColor: fillColor, + color: strokeColor, + weight: 2, + opacity: 0.8, + fillOpacity: fillOpacity + }); + } else if (shape === 'spherical') { + const radius = parseFloat(dims.radius || 10); + leafletShape = L.circle(centerLatLng, { + radius: radius, + fillColor: fillColor, + color: strokeColor, + weight: 2, + opacity: 0.8, + fillOpacity: fillOpacity, + dashArray: '5, 5' // Dashed to indicate 3D + }); + } else if (shape === 'rectangular') { + const width = parseFloat(dims.width || 10); + const length = parseFloat(dims.length || 10); + // Rectangle bounds: from center, extend width/length in both directions + const bounds = [ + [coords.x - width, coords.z - length], + [coords.x + width, coords.z + length] + ]; + leafletShape = L.rectangle(bounds, { + fillColor: fillColor, + color: strokeColor, + weight: 2, + opacity: 0.8, + fillOpacity: fillOpacity + }); + } else if (shape === 'box') { + const width = parseFloat(dims.width || 10); + const length = parseFloat(dims.length || 10); + const bounds = [ + [coords.x - width, coords.z - length], + [coords.x + width, coords.z + length] + ]; + leafletShape = L.rectangle(bounds, { + fillColor: fillColor, + color: strokeColor, + weight: 2, + opacity: 0.8, + fillOpacity: fillOpacity, + dashArray: '5, 5' // Dashed to indicate 3D + }); + } else { + // Fallback to circle + leafletShape = L.circle(centerLatLng, { + radius: 10, + fillColor: fillColor, + color: strokeColor, + weight: 2, + opacity: 0.8, + fillOpacity: fillOpacity + }); + } + + // Build popup content + const dimensionText = shape === 'circle' || shape === 'spherical' + ? `Radius: ${dims.radius || 'N/A'}` + : `Width: ${dims.width || 'N/A'}, Length: ${dims.length || 'N/A'}${shape === 'box' ? ', Height: ' + (dims.height || 'N/A') : ''}`; + + // Parse locationId to extract components + // Format: {dataset}_{owner}_{identifier} + const locationIdParts = locationId.split('_'); + const dataset = locationIdParts.slice(0, -2).join('_'); // Handle datasets with underscores + const owner = locationIdParts[locationIdParts.length - 2]; + const identifier = locationIdParts[locationIdParts.length - 1]; + + const teleportEntry = loc.teleport_entry || {}; + const hasTeleport = teleportEntry.x !== undefined && teleportEntry.y !== undefined && teleportEntry.z !== undefined; + const teleportText = hasTeleport + ? `TP: ${parseFloat(teleportEntry.x || 0).toFixed(0)}, ${parseFloat(teleportEntry.y || 0).toFixed(0)}, ${parseFloat(teleportEntry.z || 0).toFixed(0)}` + : 'TP: Not set'; + + // Use template literal for clean HTML + const popupContent = ` +
+ ${loc.name} +
${is3D ? '🎲 3D' : '⬜ 2D'} - ${shape} +

+ Type: ${loc.type && loc.type.length > 0 ? loc.type.join(', ') : 'None'} +
Owner: ${loc.owner} +
Status: ${isEnabled ? '✅ Enabled' : '❌ Disabled'} +
Position: ${coords.x.toFixed(0)}, ${coords.y.toFixed(0)}, ${coords.z.toFixed(0)} +
Dimensions: ${dimensionText} +
${teleportText} +

+
+ + +
+
+ + +
+
+ `; + + leafletShape.bindPopup(popupContent); + leafletShape.addTo(map); + + return leafletShape; +} + +// Location shapes are now loaded dynamically via Socket.IO +// Initial loading is handled by location_update events +// See location_update_handler.html for shape creation logic diff --git a/bot/modules/locations/templates/webmap/location_update_handler.html b/bot/modules/locations/templates/webmap/location_update_handler.html new file mode 100644 index 0000000..438d7a9 --- /dev/null +++ b/bot/modules/locations/templates/webmap/location_update_handler.html @@ -0,0 +1,32 @@ +// Listen for location updates/additions +if (data.data_type === 'location_update' && data.payload && data.payload.location) { + const locationId = data.payload.location_id; + const loc = data.payload.location; + + // Update locations dictionary with new data + locations[locationId] = loc; + + // Remove old shape if exists + if (locationShapes[locationId]) { + map.removeLayer(locationShapes[locationId]); + delete locationShapes[locationId]; + } + + // Create new shape + try { + const shape = createLocationShape(locationId, loc); + locationShapes[locationId] = shape; + } catch (error) { + console.error('[MAP] Error updating location shape:', error); + } +} + +// Listen for location removals +if (data.data_type === 'location_remove' && data.payload && data.payload.location_id) { + const locationId = data.payload.location_id; + + if (locationShapes[locationId]) { + map.removeLayer(locationShapes[locationId]); + delete locationShapes[locationId]; + } +} diff --git a/bot/modules/locations/triggers/location_update_on_map.py b/bot/modules/locations/triggers/location_update_on_map.py new file mode 100644 index 0000000..369510d --- /dev/null +++ b/bot/modules/locations/triggers/location_update_on_map.py @@ -0,0 +1,113 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def send_location_update_to_map(*args, **kwargs): + """Send location updates to map view via socket.io""" + module = args[0] + method = kwargs.get("method", None) + updated_values_dict = kwargs.get("updated_values_dict", None) + + if updated_values_dict is None: + return + + # Check which clients are viewing the map + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + if current_view != "map": + continue + + if method in ["upsert", "update", "edit"]: + # Send location update for each changed location + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + # updated_values_dict structure at callback depth 4: + # {location_identifier: {location_data}} + # location_data includes "owner" field + + for identifier, location_dict in updated_values_dict.items(): + if not isinstance(location_dict, dict): + continue + + # Get owner directly from location_dict + owner_steamid = location_dict.get("owner") + if owner_steamid is None: + continue + + location_id = f"{active_dataset}_{owner_steamid}_{identifier}" + + # Get full location data from DOM if fields are missing in updated_values_dict + # (e.g., when only is_enabled is updated) + full_location_dict = ( + module.dom.data + .get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(owner_steamid, {}) + .get(identifier, {}) + ) + + coordinates = location_dict.get("coordinates") + if coordinates is None: + coordinates = full_location_dict.get("coordinates", {}) + + dimensions = location_dict.get("dimensions") + if dimensions is None: + dimensions = full_location_dict.get("dimensions", {}) + + location_data = { + "name": location_dict.get("name", full_location_dict.get("name", "Unknown")), + "identifier": identifier, + "owner": owner_steamid, + "shape": location_dict.get("shape", full_location_dict.get("shape", "circle")), + "coordinates": { + "x": float(coordinates.get("x", 0)), + "y": float(coordinates.get("y", 0)), + "z": float(coordinates.get("z", 0)) + }, + "dimensions": dimensions, + "teleport_entry": location_dict.get("teleport_entry", full_location_dict.get("teleport_entry", {})), + "type": location_dict.get("type", full_location_dict.get("type", [])), + "is_enabled": location_dict.get("is_enabled", full_location_dict.get("is_enabled", False)) + } + + module.webserver.send_data_to_client_hook( + module, + payload={ + "location_id": location_id, + "location": location_data + }, + data_type="location_update", + clients=[clientid] + ) + + elif method in ["remove"]: + # Send location removal + location_origin = updated_values_dict[2] + owner_steamid = updated_values_dict[3] + location_identifier = updated_values_dict[-1] + location_id = f"{location_origin}_{owner_steamid}_{location_identifier}" + + module.webserver.send_data_to_client_hook( + module, + payload={ + "location_id": location_id + }, + data_type="location_remove", + clients=[clientid] + ) + + +trigger_meta = { + "description": "sends location updates to webmap clients", + "main_function": send_location_update_to_map, + "handlers": { + "module_locations/elements/%map_identifier%/%owner_steamid%/%element_identifier%": + send_location_update_to_map, + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/triggers/player_died.py b/bot/modules/locations/triggers/player_died.py new file mode 100644 index 0000000..c73eb02 --- /dev/null +++ b/bot/modules/locations/triggers/player_died.py @@ -0,0 +1,61 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + player_name = regex_result.group("player_name") + command = regex_result.group("command") + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + all_players_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset) + ) + + steamid = None + servertime_player_died = "n/A" + for player_steamid, player_dict in all_players_dict.items(): + if player_dict["name"] == player_name: + steamid = player_steamid + servertime_player_died = player_dict.get("last_seen_gametime", servertime_player_died) + break + + if steamid is None: + return + + if command == 'died': + event_data = ['edit_location', { + 'location_coordinates': { + "x": player_dict["pos"]["x"], + "y": player_dict["pos"]["y"], + "z": player_dict["pos"]["z"] + }, + 'location_name': "Place of Death", + 'action': 'edit', + 'location_enabled': True, + 'last_changed': servertime_player_died + }] + module.trigger_action_hook(origin_module, event_data=event_data, dispatchers_steamid=steamid) + + +trigger_meta = { + "description": "reacts to telnets player dying message", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["GMSG"]["command"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/triggers/playerspawn.py b/bot/modules/locations/triggers/playerspawn.py new file mode 100644 index 0000000..57edeef --- /dev/null +++ b/bot/modules/locations/triggers/playerspawn.py @@ -0,0 +1,63 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + if command != "EnterMultiplayer": + return + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + player_dict = { + "pos": { + "x": regex_result.group("pos_x"), + "y": regex_result.group("pos_y"), + "z": regex_result.group("pos_z") + } + } + player_steamid = regex_result.group("player_steamid") + first_seen_gametime_string = module.game_environment.get_last_recorded_gametime_string() + + event_data = ['edit_location', { + 'location_owner': player_steamid, + 'location_coordinates': { + "x": player_dict["pos"]["x"], + "y": player_dict["pos"]["y"], + "z": player_dict["pos"]["z"] + }, + 'location_name': "Initial Spawn", + 'action': 'create_new', + 'location_enabled': True, + 'last_changed': first_seen_gametime_string + }] + module.trigger_action_hook(origin_module, event_data=event_data) + + +trigger_meta = { + "description": "reacts to any initial playerspawn", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"PlayerSpawnedInWorld\s" + r"\(" + r"reason: (?P.+?),\s" + r"position: (?P.*),\s(?P.*),\s(?P.*)" + r"\):\s" + r"EntityID=(?P.*),\s" + r"PlayerID='(?P.*)',\s" + r"OwnerID='(?P.*)',\s" + r"PlayerName='(?P.*)'" + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/triggers/zombiespawn.py b/bot/modules/locations/triggers/zombiespawn.py new file mode 100644 index 0000000..8389fde --- /dev/null +++ b/bot/modules/locations/triggers/zombiespawn.py @@ -0,0 +1,86 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + position_dict = { + "pos": { + "x": regex_result.group("pos_x"), + "y": regex_result.group("pos_y"), + "z": regex_result.group("pos_z") + } + } + zombie_id = regex_result.group("entity_id") + zombie_name = regex_result.group("zombie_name") + + screamer_safe_locations = [] + found_screamer_safe_location = False + for screamer_safe_location in origin_module.get_elements_by_type("is_screamerfree"): + screamer_safe_locations.append(screamer_safe_location) + found_screamer_safe_location = True + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if zombie_name == "zombieScreamer": + for location_dict in screamer_safe_locations: + if origin_module.locations.position_is_inside_boundary(position_dict, location_dict): + event_data = ['manage_entities', { + 'dataset': active_dataset, + 'entity_id': zombie_id, + 'entity_name': zombie_name, + 'action': 'kill' + }] + # no steamid cause it's a system_call + module.trigger_action_hook(origin_module.game_environment, event_data=event_data) + + event_data = ['say_to_all', { + 'message': ( + '[FF6666]Screamer ([FFFFFF]{entity_id}[FF6666]) spawned[-] ' + '[FFFFFF]inside [CCCCFF]{location_name}[FFFFFF].[-]'.format( + entity_id=zombie_id, + location_name=location_dict.get("name") + ) + ) + }] + module.trigger_action_hook(origin_module.game_environment, event_data=event_data) + # we only need to match one location. even though a screamer can be in multiple locations at once, + # we still only have to kill it once :) + break + else: + if found_screamer_safe_location: + event_data = ['say_to_all', { + 'message': ( + '[FF6666]Screamer ([FFFFFF]{entity_id}[FF6666]) spawned[-] ' + '[CCCCFF]somewhere[FFFFFF]...[-]'.format( + entity_id=zombie_id + ) + ) + }] + module.trigger_action_hook(origin_module.game_environment, event_data=event_data) + + +trigger_meta = { + "description": "reacts to spawning zombies (screamers, mostly)", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"(?P.+?)\s" + r"\[" + r"type=(.*),\s" + r"name=(?P.+?),\s" + r"id=(?P.*)\]\sat\s\((?P.*),\s(?P.*),\s(?P.*)\)\s" + r"Day=(\d.*)\s" + r"TotalInWave=(\d.*)\s" + r"CurrentWave=(\d.*)" + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/locations/widgets/manage_locations_widget.py b/bot/modules/locations/widgets/manage_locations_widget.py new file mode 100644 index 0000000..752e143 --- /dev/null +++ b/bot/modules/locations/widgets/manage_locations_widget.py @@ -0,0 +1,1041 @@ +from bot import loaded_modules_dict +from os import path, pardir +import json + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + +# View Registry - defines all available views and their navigation labels +# This eliminates the need for separate template files for each button +VIEW_REGISTRY = { + 'frontend': { + 'label_active': 'back', + 'label_inactive': 'main', + 'action': 'show_frontend', + 'include_in_menu': False + }, + 'options': { + 'label_active': 'back', + 'label_inactive': 'options', + 'action': 'show_options', + 'include_in_menu': True + }, + 'map': { + 'label_active': 'hide map', + 'label_inactive': 'show map', + 'action': 'show_map', + 'include_in_menu': True + }, + 'create_new': { + 'label_active': 'back', + 'label_inactive': 'create new', + 'action': 'show_create_new', + 'include_in_menu': True + }, + 'special_locations': { + 'label_active': 'back', + 'label_inactive': 'special', + 'action': 'show_special_locations', + 'include_in_menu': True + } +} + + +def get_table_row_css_class(location_dict): + is_enabled = location_dict.get("is_enabled", False) + + if is_enabled: + css_class = "is_enabled" + else: + css_class = "" + + return css_class + + +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + current_view = module.get_current_view(dispatchers_steamid) + if current_view == "options": + options_view(module, dispatchers_steamid=dispatchers_steamid, current_view=current_view) + elif current_view == "map": + map_view(module, dispatchers_steamid=dispatchers_steamid, current_view=current_view) + elif current_view == "special_locations": + frontend_view(module, dispatchers_steamid=dispatchers_steamid, current_view=current_view) + elif current_view == "delete-modal": + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + delete_modal_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "create_new": + edit_view(module, dispatchers_steamid=dispatchers_steamid, current_view=current_view) + elif current_view == "edit_location_entry": + location_owner = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("location_owner", None) + ) + location_identifier = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("location_identifier", None) + ) + location_origin = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("location_origin", None) + ) + + edit_view( + module, + dispatchers_steamid=dispatchers_steamid, + location_owner=location_owner, + location_identifier=location_identifier, + location_origin=location_origin, + current_view=current_view + ) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + + +def delete_modal_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + all_available_locations = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + all_selected_elements_count = 0 + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + for map_identifier, location_owner in all_available_locations.items(): + if active_dataset == map_identifier: + for player_steamid, player_locations in location_owner.items(): + for identifier, location_dict in player_locations.items(): + location_is_selected_by = location_dict.get("selected_by", []) + if dispatchers_steamid in location_is_selected_by: + all_selected_elements_count += 1 + + modal_confirm_delete = module.dom_management.get_delete_confirm_modal( + module, + count=all_selected_elements_count, + target_module="module_locations", + dom_element_id="location_table_modal_action_delete_button", + dom_action="delete_selected_dom_elements", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + confirmed="True" + ) + + data_to_emit = modal_confirm_delete + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="modal_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget_modal", + "type": "div", + "selector": "body > main > div" + } + ) + + +def frontend_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + current_view = kwargs.get("current_view", None) + + # Load templates + template_frontend = module.templates.get_template('manage_locations_widget/view_frontend.html') + template_view_menu = module.templates.get_template('manage_locations_widget/control_view_menu.html') + control_player_location_view_template = module.templates.get_template( + 'manage_locations_widget/control_player_location.html' + ) + control_edit_link = module.templates.get_template('manage_locations_widget/control_edit_link.html') + control_enabled_link = module.templates.get_template('manage_locations_widget/control_enabled_link.html') + + template_table_header = module.templates.get_template('manage_locations_widget/table_header.html') + template_table_rows = module.templates.get_template('manage_locations_widget/table_row.html') + template_table_footer = module.templates.get_template('manage_locations_widget/table_footer.html') + + # Build table rows efficiently using list + join (avoids O(n²) with string +=) + table_rows_list = [] + all_available_locations = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + all_selected_elements_count = 0 + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + for map_identifier, location_owner in all_available_locations.items(): + if active_dataset == map_identifier: + for player_steamid, player_locations in location_owner.items(): + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + for identifier, location_dict in player_locations.items(): + location_has_special_properties = len(location_dict.get("type", [])) >= 1 + if not location_has_special_properties and current_view == "special_locations": + continue + + location_is_selected_by = location_dict.get("selected_by", []) + + location_entry_selected = False + if dispatchers_steamid in location_is_selected_by: + location_entry_selected = True + all_selected_elements_count += 1 + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + location_dict_for_template = location_dict.copy() + location_dict_for_template["dataset"] = module.dom_management.sanitize_for_html_id(location_dict.get("dataset", "")) + location_dict_for_template["dataset_original"] = location_dict.get("dataset", "") + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_locations", + dom_element_select_root=[identifier, "selected_by"], + dom_element=location_dict_for_template, + dom_element_entry_selected=location_entry_selected, + dom_action_inactive="select_dom_element", + dom_action_active="deselect_dom_element" + ) + + table_rows_list.append(module.template_render_hook( + module, + template=template_table_rows, + location=location_dict_for_template, + player_dict=player_dict, + control_select_link=control_select_link, + control_enabled_link=module.template_render_hook( + module, + template=control_enabled_link, + location=location_dict_for_template, + ), + control_edit_link=module.template_render_hook( + module, + template=control_edit_link, + dispatchers_steamid=dispatchers_steamid, + location=location_dict_for_template, + ) + )) + + table_rows = ''.join(table_rows_list) + + dom_element_delete_button = module.dom_management.get_delete_button_dom_element( + module, + count=all_selected_elements_count, + target_module="module_locations", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + dom_element_id="manage_locations_control_action_delete_link", + dom_action="delete_selected_dom_elements" + ) + + # Get current view and player coordinates + current_view = module.get_current_view(dispatchers_steamid) + player_coordinates = module.dom.data.get("module_players", {}).get("players", {}).get(dispatchers_steamid, {}).get( + "pos", {"x": 0, "y": 0, "z": 0} + ) + + # Render navigation menu using view registry + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid, + control_player_location_view=module.template_render_hook( + module, + template=control_player_location_view_template, + pos_x=player_coordinates["x"], + pos_y=player_coordinates["y"], + pos_z=player_coordinates["z"] + ) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + table_header=module.template_render_hook( + module, + template=template_table_header + ), + table_rows=table_rows, + table_footer=module.template_render_hook( + module, + template=template_table_footer, + action_delete_button=dom_element_delete_button + ) + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def map_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + current_view = kwargs.get("current_view", None) + + # Load templates + template_map = module.templates.get_template('manage_locations_widget/view_map.html') + template_view_menu = module.templates.get_template('manage_locations_widget/control_view_menu.html') + control_player_location_view_template = module.templates.get_template( + 'manage_locations_widget/control_player_location.html' + ) + + # Get current view and player coordinates + current_view = module.get_current_view(dispatchers_steamid) + player_coordinates = module.dom.data.get("module_players", {}).get("players", {}).get(dispatchers_steamid, {}).get( + "pos", {"x": 0, "y": 0, "z": 0} + ) + + # Collect all locations for the map + all_locations = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + locations_for_map = {} + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + for map_identifier, location_owners in all_locations.items(): + if active_dataset and map_identifier != active_dataset: + continue + for owner_steamid, player_locations in location_owners.items(): + for identifier, location_dict in player_locations.items(): + location_id = f"{map_identifier}_{owner_steamid}_{identifier}" + coordinates = location_dict.get("coordinates", {}) + dimensions = location_dict.get("dimensions", {}) + shape = location_dict.get("shape", "circle") + + locations_for_map[location_id] = { + "name": location_dict.get("name", "Unknown"), + "identifier": identifier, + "owner": owner_steamid, + "shape": shape, + "coordinates": { + "x": float(coordinates.get("x", 0)), + "y": float(coordinates.get("y", 0)), + "z": float(coordinates.get("z", 0)) + }, + "dimensions": dimensions, + "teleport_entry": location_dict.get("teleport_entry", {}), + "type": location_dict.get("type", []), + "is_enabled": location_dict.get("is_enabled", False) + } + + # Collect all online players for the map + players_module = loaded_modules_dict.get("module_players") + players_for_map = {} + + if players_module: + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + all_players = players_module.dom.data.get("module_players", {}).get("elements", {}) + + if active_dataset and active_dataset in all_players: + for steamid, player_dict in all_players[active_dataset].items(): + if player_dict.get("is_online", False): + players_for_map[steamid] = { + "name": player_dict.get("name", "Player"), + "level": player_dict.get("level", 0), + "health": player_dict.get("health", 0), + "zombies": player_dict.get("zombies", 0), + "players": player_dict.get("players", 0), + "deaths": player_dict.get("deaths", 0), + "score": player_dict.get("score", 0), + "ping": player_dict.get("ping", 0), + "is_authenticated": player_dict.get("is_authenticated", False), + "is_muted": player_dict.get("is_muted", False), + "is_initialized": player_dict.get("is_initialized", False), + "in_limbo": player_dict.get("in_limbo", False), + "permission_level": player_dict.get("permission_level", None), + "dataset": active_dataset, + "pos": { + "x": player_dict.get("pos", {}).get("x", 0), + "y": player_dict.get("pos", {}).get("y", 0), + "z": player_dict.get("pos", {}).get("z", 0) + } + } + + # Get gameprefs for map legend + gameprefs = {} + if active_dataset: + gameprefs = ( + module.dom.data + .get("module_game_environment", {}) + .get(active_dataset, {}) + .get("gameprefs", {}) + ) + + # Load webmap templates from players and locations modules + webmap_templates = {} + + # Load player webmap templates + if players_module: + try: + webmap_templates['player_popup'] = players_module.templates.get_template('webmap/player_popup.html').render() + webmap_templates['player_markers'] = players_module.templates.get_template('webmap/player_markers.html').render() + webmap_templates['player_update_handler'] = players_module.templates.get_template('webmap/player_update_handler.html').render() + webmap_templates['player_actions'] = players_module.templates.get_template('webmap/player_actions.html').render() + except Exception as e: + module.logger.error(f"Error loading player webmap templates: {e}") + + # Load location webmap templates + try: + webmap_templates['location_shapes'] = module.templates.get_template('webmap/location_shapes.html').render() + webmap_templates['location_update_handler'] = module.templates.get_template('webmap/location_update_handler.html').render() + webmap_templates['location_actions'] = module.templates.get_template('webmap/location_actions.html').render() + except Exception as e: + module.logger.error(f"Error loading location webmap templates: {e}") + + # Render navigation menu using view registry + control_switch_view = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid, + control_player_location_view=module.template_render_hook( + module, + template=control_player_location_view_template, + pos_x=player_coordinates["x"], + pos_y=player_coordinates["y"], + pos_z=player_coordinates["z"] + ) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_map, + control_switch_view=control_switch_view, + webmap_templates=webmap_templates + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + # Send map metadata via Socket.IO + module.webserver.send_data_to_client_hook( + module, + payload={ + "gameprefs": gameprefs, + "active_dataset": active_dataset or "Unknown" + }, + data_type="map_metadata", + clients=[dispatchers_steamid] + ) + + # Send initial player data via Socket.IO + for steamid, player_data in players_for_map.items(): + player_update_data = { + "steamid": steamid, + "name": player_data.get("name", "Player"), + "level": player_data.get("level", 0), + "health": player_data.get("health", 0), + "zombies": player_data.get("zombies", 0), + "deaths": player_data.get("deaths", 0), + "players": player_data.get("players", 0), + "score": player_data.get("score", 0), + "ping": player_data.get("ping", 0), + "is_muted": player_data.get("is_muted", False), + "is_authenticated": player_data.get("is_authenticated", False), + "in_limbo": player_data.get("in_limbo", False), + "is_initialized": player_data.get("is_initialized", False), + "permission_level": player_data.get("permission_level", None), + "dataset": player_data.get("dataset", ""), + "position": { + "x": float(player_data.get("pos", {}).get("x", 0)), + "y": float(player_data.get("pos", {}).get("y", 0)), + "z": float(player_data.get("pos", {}).get("z", 0)) + } + } + module.webserver.send_data_to_client_hook( + module, + payload=player_update_data, + data_type="player_position_update", + clients=[dispatchers_steamid] + ) + + # Send initial location data via Socket.IO + for location_id, location_data in locations_for_map.items(): + module.webserver.send_data_to_client_hook( + module, + payload={ + "location_id": location_id, + "location": location_data + }, + data_type="location_update", + clients=[dispatchers_steamid] + ) + + +def options_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + # Load templates + template_frontend = module.templates.get_template('manage_locations_widget/view_options.html') + template_view_menu = module.templates.get_template('manage_locations_widget/control_view_menu.html') + + # Get current view + current_view = module.get_current_view(dispatchers_steamid) + + # Render navigation menu using view registry + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid, + control_player_location_view='' # No player location in options view + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + widget_options=module.options, + available_actions=module.available_actions_dict, + available_triggers=module.available_triggers_dict, + available_widgets=module.available_widgets_dict + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def special_locations_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + # Load templates + template_frontend = module.templates.get_template('manage_locations_widget/view_special_locations.html') + template_view_menu = module.templates.get_template('manage_locations_widget/control_view_menu.html') + + # Get current view + current_view = module.get_current_view(dispatchers_steamid) + + # Render navigation menu using view registry + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid, + control_player_location_view='' + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def edit_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + edit_mode = kwargs.get("current_view", None) + + location_to_edit_dict = {} + if edit_mode == "edit_location_entry": + location_owner = kwargs.get("location_owner", None) + location_identifier = kwargs.get("location_identifier", None) + location_origin = kwargs.get("location_origin", None) + if all([ + location_owner is not None, + location_identifier is not None, + location_origin is not None + ]): + location_to_edit_dict = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("elements", {}) + .get(location_origin) + .get(location_owner) + .get(location_identifier) + ) + if edit_mode == "create_new": + location_to_edit_dict = { + "owner": str(dispatchers_steamid), + "is_enabled": False + } + + # Check for prefilled coordinates from map + prefill_coords = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("prefill_coordinates", None) + ) + + if prefill_coords: + location_to_edit_dict["coordinates"] = { + "x": float(prefill_coords.get("x", 0)), + "y": float(prefill_coords.get("y", 0)), + "z": float(prefill_coords.get("z", 0)) + } + # Also prefill teleport_entry with same coordinates + location_to_edit_dict["teleport_entry"] = { + "x": float(prefill_coords.get("x", 0)), + "y": float(prefill_coords.get("y", 0)), + "z": float(prefill_coords.get("z", 0)) + } + + # Load templates + template_frontend = module.templates.get_template('manage_locations_widget/view_create_new.html') + template_view_menu = module.templates.get_template('manage_locations_widget/control_view_menu.html') + control_player_location_view_template = module.templates.get_template( + 'manage_locations_widget/control_player_location.html' + ) + + # Get current view and player coordinates + current_view = module.get_current_view(dispatchers_steamid) + player_coordinates = module.dom.data.get("module_players", {}).get("players", {}).get(dispatchers_steamid, {}).get( + "pos", {"x": 0, "y": 0, "z": 0} + ) + + # Render navigation menu using view registry + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid, + control_player_location_view=module.template_render_hook( + module, + template=control_player_location_view_template, + pos_x=player_coordinates["x"], + pos_y=player_coordinates["y"], + pos_z=player_coordinates["z"] + ) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + widget_options=module.options, + location_to_edit_dict=location_to_edit_dict + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_locations_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def table_row(*args, **kwargs): + module = args[0] + method = kwargs.get("method", None) + updated_values_dict = kwargs.get("updated_values_dict", None) + + template_table_rows = module.templates.get_template('manage_locations_widget/table_row.html') + + control_edit_link = module.templates.get_template('manage_locations_widget/control_edit_link.html') + control_enabled_link = module.templates.get_template('manage_locations_widget/control_enabled_link.html') + + if updated_values_dict is not None: + if method in ["upsert", "update", "edit"]: + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + visibility_conditions = [ + current_view == "frontend" + ] + if any(visibility_conditions): # only relevant if the table is shown + for player_steamid, locations in updated_values_dict.items(): + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + for identifier, location_dict in locations.items(): + location_is_selected_by = location_dict.get("selected_by", []) + + location_entry_selected = False + if clientid in location_is_selected_by: + location_entry_selected = True + + try: + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(updated_values_dict[player_steamid][identifier]["dataset"]) + table_row_id = "location_table_row_{}_{}_{}".format( + sanitized_dataset, + str(player_steamid), + str(identifier) + ) + # Update location_dict with sanitized dataset for template + location_dict = location_dict.copy() + location_dict["dataset"] = sanitized_dataset + location_dict["dataset_original"] = updated_values_dict[player_steamid][identifier].get("dataset", "") + except KeyError: + table_row_id = "manage_locations_widget" + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_locations", + dom_element_select_root=[identifier, "selected_by"], + dom_element=location_dict, + dom_element_entry_selected=location_entry_selected, + dom_action_inactive="select_dom_element", + dom_action_active="deselect_dom_element" + ) + rendered_table_row = module.template_render_hook( + module, + template=template_table_rows, + location=location_dict, + player_dict=player_dict, + control_select_link=control_select_link, + control_enabled_link=module.template_render_hook( + module, + template=control_enabled_link, + location=location_dict, + ), + control_edit_link=module.template_render_hook( + module, + template=control_edit_link, + location=location_dict, + ), + css_class=get_table_row_css_class(location_dict) + ) + + module.webserver.send_data_to_client_hook( + module, + payload=rendered_table_row, + data_type="table_row", + clients=[clientid], + target_element={ + "id": table_row_id, + "type": "tr", + "class": get_table_row_css_class(location_dict), + "selector": "body > main > div > div#manage_locations_widget > main > table > tbody" + } + ) + else: # table is not visible or current user, skip it! + continue + elif method in ["remove"]: # callback_dict sent us here with a removal notification! + location_origin = updated_values_dict[2] + player_steamid = updated_values_dict[3] + location_identifier = updated_values_dict[-1] + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_origin = module.dom_management.sanitize_for_html_id(location_origin) + + module.webserver.send_data_to_client_hook( + module, + data_type="remove_table_row", + clients="all", + target_element={ + "id": "location_table_row_{}_{}_{}".format( + sanitized_origin, + str(player_steamid), + str(location_identifier) + ), + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_player_location(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + webserver_logged_in_users = module.dom.data.get("module_webserver", {}).get( + "webserver_logged_in_users", [] + ) + + dispatchers_steamid = updated_values_dict.get("steamid") + if dispatchers_steamid not in webserver_logged_in_users: + return + + control_player_location_view = module.templates.get_template( + 'manage_locations_widget/control_player_location.html' + ) + + player_coordinates = updated_values_dict.get("pos", {}) + + data_to_emit = module.template_render_hook( + module, + template=control_player_location_view, + pos_x=player_coordinates["x"], + pos_y=player_coordinates["y"], + pos_z=player_coordinates["z"] + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="element_content", + method="replace", + clients=dispatchers_steamid, + target_element={ + "id": "current_player_pos" + } + ) + + +def update_selection_status(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + location_identifier = updated_values_dict["identifier"] + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(updated_values_dict["dataset"]) + + module.dom_management.update_selection_status( + *args, **kwargs, + target_module=module, + dom_element_root=[location_identifier], + dom_element_select_root=[location_identifier, "selected_by"], + dom_action_active="deselect_dom_element", + dom_action_inactive="select_dom_element", + dom_element_id={ + "id": "location_table_row_{}_{}_{}_control_select_link".format( + sanitized_dataset, + updated_values_dict["owner"], + updated_values_dict["identifier"] + ) + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_enabled_flag(*args, **kwargs): + module = args[0] + original_values_dict = kwargs.get("original_values_dict", None) + + control_enable_link = module.templates.get_template('manage_locations_widget/control_enabled_link.html') + + location_origin = original_values_dict.get("dataset", None) + location_owner = original_values_dict.get("owner", None) + location_identifier = original_values_dict.get("identifier", None) + + location_dict = ( + module.dom.data.get("module_locations", {}) + .get("elements", {}) + .get(location_origin, {}) + .get(location_owner, {}) + .get(location_identifier, None) + ) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + location_dict_sanitized = location_dict.copy() + location_dict_sanitized["dataset"] = module.dom_management.sanitize_for_html_id(location_origin) + location_dict_sanitized["dataset_original"] = location_origin + + data_to_emit = module.template_render_hook( + module, + template=control_enable_link, + location=location_dict_sanitized, + ) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_origin = module.dom_management.sanitize_for_html_id(location_origin) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="element_content", + clients="all", + method="update", + target_element={ + "id": "location_table_row_{}_{}_{}_control_enabled_link".format( + sanitized_origin, + location_owner, + location_identifier + ), + } + ) + + +def update_delete_button_status(*args, **kwargs): + module = args[0] + + module.dom_management.update_delete_button_status( + *args, **kwargs, + target_module=module, + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + dom_action="delete_selected_dom_elements", + dom_element_id={ + "id": "manage_locations_control_action_delete_link" + } + ) + + +def update_location_on_map(*args, **kwargs): + """Send location updates to map view via socket.io""" + module = args[0] + method = kwargs.get("method", None) + updated_values_dict = kwargs.get("updated_values_dict", None) + + if updated_values_dict is None: + return + + # Check which clients are viewing the map + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + if current_view != "map": + continue + + if method in ["upsert", "update", "edit"]: + # Send location update for each changed location + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + # updated_values_dict structure at callback depth 4: + # {location_identifier: {location_data}} + # location_data includes "owner" field + + for identifier, location_dict in updated_values_dict.items(): + if not isinstance(location_dict, dict): + continue + + # Get owner directly from location_dict + owner_steamid = location_dict.get("owner") + if owner_steamid is None: + continue + + location_id = f"{active_dataset}_{owner_steamid}_{identifier}" + + # Get full location data from DOM if fields are missing in updated_values_dict + # (e.g., when only is_enabled is updated) + full_location_dict = ( + module.dom.data + .get("module_locations", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(owner_steamid, {}) + .get(identifier, {}) + ) + + coordinates = location_dict.get("coordinates") + if coordinates is None: + coordinates = full_location_dict.get("coordinates", {}) + + dimensions = location_dict.get("dimensions") + if dimensions is None: + dimensions = full_location_dict.get("dimensions", {}) + + location_data = { + "name": location_dict.get("name", full_location_dict.get("name", "Unknown")), + "identifier": identifier, + "owner": owner_steamid, + "shape": location_dict.get("shape", full_location_dict.get("shape", "circle")), + "coordinates": { + "x": float(coordinates.get("x", 0)), + "y": float(coordinates.get("y", 0)), + "z": float(coordinates.get("z", 0)) + }, + "dimensions": dimensions, + "teleport_entry": location_dict.get("teleport_entry", full_location_dict.get("teleport_entry", {})), + "type": location_dict.get("type", full_location_dict.get("type", [])), + "is_enabled": location_dict.get("is_enabled", full_location_dict.get("is_enabled", False)) + } + + module.webserver.send_data_to_client_hook( + module, + payload={ + "location_id": location_id, + "location": location_data + }, + data_type="location_update", + clients=[clientid] + ) + + elif method in ["remove"]: + # Send location removal + location_origin = updated_values_dict[2] + owner_steamid = updated_values_dict[3] + location_identifier = updated_values_dict[-1] + location_id = f"{location_origin}_{owner_steamid}_{location_identifier}" + + module.webserver.send_data_to_client_hook( + module, + payload={ + "location_id": location_id + }, + data_type="location_remove", + clients=[clientid] + ) + + +widget_meta = { + "description": "shows locations and stuff", + "main_widget": select_view, + "handlers": { + # the %abc% placeholders can contain any text at all, it has no effect on anything but code-readability + # the third line could just as well read + # "module_locations/elements/%x%/%x%/%x%/selected_by": update_selection_status + # and would still function the same as + # "module_locations/elements/%map_identifier%/%steamid%/%element_identifier%/selected_by": + # update_selection_status + "module_locations/visibility/%steamid%/current_view": + select_view, + "module_locations/elements/%map_identifier%/%steamid%": + table_row, + "module_locations/elements/%map_identifier%/%owner_steamid%/%element_identifier%": + update_location_on_map, + "module_locations/elements/%map_identifier%/%steamid%/%element_identifier%/selected_by": + update_selection_status, + "module_locations/elements/%map_identifier%/%steamid%/%element_identifier%/is_enabled": + update_enabled_flag, + "module_players/elements/%map_identifier%/%steamid%/pos": + update_player_location + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/permissions/__init__.py b/bot/modules/permissions/__init__.py new file mode 100644 index 0000000..dd247fa --- /dev/null +++ b/bot/modules/permissions/__init__.py @@ -0,0 +1,202 @@ +from bot.module import Module +from bot import loaded_modules_dict +from bot.logger import get_logger +from bot.constants import ( + PERMISSION_LEVEL_DEFAULT, + PERMISSION_LEVEL_BUILDER, + PERMISSION_LEVEL_PLAYER, + is_moderator_or_higher, + is_builder_or_higher +) + +logger = get_logger("permissions") + + +class Permissions(Module): + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:], + "default_player_password": None + }) + + setattr(self, "required_modules", [ + 'module_dom', + 'module_players', + 'module_locations', + 'module_webserver' + ]) + + self.next_cycle = 0 + self.run_observer_interval = 5 + self.all_available_actions_dict = {} + self.all_available_widgets_dict = {} + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_permissions" + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + # endregion + + def start(self): + """ all modules have been loaded and initialized by now. we can bend the rules here.""" + self.set_permission_hooks() + self.all_available_actions_dict = self.get_all_available_actions_dict() + self.all_available_widgets_dict = self.get_all_available_widgets_dict() + Module.start(self) + # endregion + + # ==================== Permission Check Helpers ==================== + + @staticmethod + def _is_owner(steamid: str, event_data: list) -> bool: + """Check if user is the owner of the element being modified.""" + return str(steamid) == event_data[1].get("dom_element_owner", "") + + @staticmethod + def _check_toggle_flag_permission(permission_level: int, steamid: str, event_data: list) -> bool: + """Check permission for toggle_enabled_flag action.""" + if event_data[0] != "toggle_enabled_flag": + return False + + # Builders and below can only edit their own elements + if permission_level >= PERMISSION_LEVEL_BUILDER: + return not Permissions._is_owner(steamid, event_data) + return False + + @staticmethod + def _check_widget_options_permission(permission_level: int, event_data: list) -> bool: + """Check permission for widget options view.""" + if not (event_data[0].startswith("toggle_") and event_data[0].endswith("_widget_view")): + return False + + if event_data[1].get("action") == "show_options": + # Only moderators and admins can see options + return not is_moderator_or_higher(permission_level) + return False + + @staticmethod + def _check_dom_management_permission(permission_level: int, steamid: str, event_data: list) -> bool: + """Check permissions for DOM management actions.""" + action_name = event_data[0] + sub_action = event_data[1].get("action", "") + + if action_name not in ["delete", "select"]: + return False + + # Select/deselect: builders and below can only modify their own elements + if sub_action in ["select_dom_element", "deselect_dom_element"]: + if permission_level >= PERMISSION_LEVEL_BUILDER: + return not Permissions._is_owner(steamid, event_data) + return False + + # Delete: only moderators and admins + if sub_action == "delete_selected_dom_elements": + return permission_level >= PERMISSION_LEVEL_BUILDER + + return False + + @staticmethod + def _check_players_permission(permission_level: int, event_data: list) -> bool: + """Check permissions for player management actions.""" + if event_data[0] == "kick_player": + # Only builder and above can kick players + return permission_level > PERMISSION_LEVEL_BUILDER + return False + + @staticmethod + def _check_locations_permission(permission_level: int, steamid: str, event_data: list) -> bool: + """Check permissions for location management actions.""" + action_name = event_data[0] + sub_action = event_data[1].get("action", "") + + if action_name not in ["edit_location", "management_tools", "toggle_locations_widget_view"]: + return False + + # Edit/enable/disable: builders and below can only modify their own locations + if sub_action in ["edit_location_entry", "enable_location_entry", "disable_location_entry"]: + if permission_level >= PERMISSION_LEVEL_BUILDER: + return not Permissions._is_owner(steamid, event_data) + return False + + # Create new: only players and above + if sub_action == "show_create_new": + return permission_level > PERMISSION_LEVEL_PLAYER + + return False + + @staticmethod + def _check_telnet_permission(permission_level: int, event_data: list) -> bool: + """Check permissions for telnet actions.""" + if event_data[0] == "shutdown": + # Only moderators and admins can shutdown server + return permission_level >= PERMISSION_LEVEL_BUILDER + return False + + # ==================== Main Permission Check ==================== + + @staticmethod + def trigger_action_with_permission(*args, **kwargs): + """ + Check permissions before triggering an action. + + Permissions default to allowed if no specific rule matches. + Module-specific permission checks are delegated to helper methods. + """ + module = args[0] + event_data = kwargs.get("event_data", []) + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + # Default to allowing action + permission_denied = False + + if dispatchers_steamid is not None: + # Get user's permission level + permission_level = int( + module.dom.data.get("module_players", {}).get("admins", {}).get( + dispatchers_steamid, PERMISSION_LEVEL_DEFAULT + ) + ) + module_identifier = module.get_module_identifier() + + # Run permission checks based on action and module + permission_denied = ( + Permissions._check_toggle_flag_permission(permission_level, dispatchers_steamid, event_data) or + Permissions._check_widget_options_permission(permission_level, event_data) or + (module_identifier == "module_dom_management" and + Permissions._check_dom_management_permission(permission_level, dispatchers_steamid, event_data)) or + (module_identifier == "module_players" and + Permissions._check_players_permission(permission_level, event_data)) or + (module_identifier == "module_locations" and + Permissions._check_locations_permission(permission_level, dispatchers_steamid, event_data)) or + (module_identifier == "module_telnet" and + Permissions._check_telnet_permission(permission_level, event_data)) + ) + + if permission_denied: + logger.warn("permission_denied", + action=event_data[0], + user=dispatchers_steamid, + permission_level=permission_level) + + event_data[1]["has_permission"] = not permission_denied + + # Execute the action + return module.trigger_action(module, event_data=event_data, dispatchers_steamid=dispatchers_steamid) + + @staticmethod + def template_render_hook_with_permission(*args, **kwargs): + module = args[0] + return module.template_render(*args, **kwargs) + + def set_permission_hooks(self): + for identifier, module in loaded_modules_dict.items(): + module.trigger_action_hook = self.trigger_action_with_permission + module.template_render_hook = self.template_render_hook_with_permission + + +loaded_modules_dict[Permissions().get_module_identifier()] = Permissions() diff --git a/bot/modules/permissions/actions/set_authentication.py b/bot/modules/permissions/actions/set_authentication.py new file mode 100644 index 0000000..a16e378 --- /dev/null +++ b/bot/modules/permissions/actions/set_authentication.py @@ -0,0 +1,75 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + player_steamid = event_data[1].get("player_steamid", None) + dataset = event_data[1].get("dataset", None) + entered_password = event_data[1].get("entered_password", None) + default_player_password = module.default_options.get("default_player_password", None) + + if all([ + dataset is not None, + player_steamid is not None, + entered_password is not None, + default_player_password is not None + ]): + if default_player_password == entered_password: + is_authenticated = True + else: + is_authenticated = False + + module.dom.data.upsert({ + "module_players": { + "elements": { + dataset: { + player_steamid: { + "is_authenticated": is_authenticated + } + } + } + } + }, dispatchers_steamid=player_steamid) + + if is_authenticated is True: + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + player_steamid = event_data[1].get("player_steamid", None) + if player_steamid is not None: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[66FF66]Thank you for playing along[-][FFFFFF], you may now leave the Lobby-area[-]' + }] + module.trigger_action_hook(module.players, event_data=event_data) + + +def callback_fail(module, event_data, dispatchers_steamid): + player_steamid = event_data[1].get("player_steamid", None) + if player_steamid is not None: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FF6666]Could not authenticate[-][FFFFFF], wrong password perhaps?[-]' + }] + module.trigger_action_hook(module.players, event_data=event_data) + pass + + +action_meta = { + "description": "sets a players authenticated flag", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/permissions/actions/set_player_mute.py b/bot/modules/permissions/actions/set_player_mute.py new file mode 100644 index 0000000..094db83 --- /dev/null +++ b/bot/modules/permissions/actions/set_player_mute.py @@ -0,0 +1,89 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + event_data[1]["action_identifier"] = action_name + player_steamid = event_data[1].get("player_steamid", None) + active_dataset = event_data[1].get("dataset", None) + flag_player_to_be_muted = event_data[1].get("is_muted", None) + + if all([ + active_dataset is not None, + player_steamid is not None, + flag_player_to_be_muted is not None, + ]): + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + player_is_currently_muted = player_dict.get("is_muted", False) + + if not flag_player_to_be_muted: + default_player_password = module.default_options.get("default_player_password", None) + if player_is_currently_muted or default_player_password is None: + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + player_steamid = event_data[1].get("player_steamid", None) + active_dataset = event_data[1].get("dataset", None) + + if player_steamid is not None: + default_player_password = module.default_options.get("default_player_password", None) + if default_player_password is not None: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[66FF66]Free speech[-][FFFFFF], you may now chat. Say hello ^^[-]' + }] + module.trigger_action_hook(module.players, event_data=event_data) + + event_data = ['toggle_player_mute', { + 'steamid': player_steamid, + 'mute_status': False, + 'dataset': active_dataset + }] + module.trigger_action_hook(module.players, event_data=event_data) + + +def callback_fail(module, event_data, dispatchers_steamid): + player_steamid = event_data[1].get("player_steamid", None) + active_dataset = event_data[1].get("dataset", None) + + if player_steamid is not None: + default_player_password = module.default_options.get("default_player_password", None) + if default_player_password is not None: + event_data = ['say_to_player', { + 'steamid': player_steamid, + 'message': '[FF6666]You have been automatically muted[-][FFFFFF], until you have authenticated![-]' + }] + module.trigger_action_hook(module.players, event_data=event_data) + + event_data = ['toggle_player_mute', { + 'steamid': player_steamid, + 'mute_status': True, + 'dataset': active_dataset + }] + module.trigger_action_hook(module.players, event_data=event_data) + + +action_meta = { + "description": "tools to help managing a players ability to chat (and speak?)", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/permissions/commands/password.py b/bot/modules/permissions/commands/password.py new file mode 100644 index 0000000..30ce0de --- /dev/null +++ b/bot/modules/permissions/commands/password.py @@ -0,0 +1,57 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + steamid = regex_result.group("player_steamid") + + result = re.match(r"^.*password\s(?P.*)", command) + if result: + entered_password = result.group("password") + else: + return + + event_data = ['set_authentication', { + 'dataset': module.dom.data.get("module_game_environment", {}).get("active_dataset", None), + 'player_steamid': steamid, + 'entered_password': entered_password + }] + module.trigger_action_hook(origin_module, event_data=event_data) + + +triggers = { + "password": r"\'(?P.*)\'\:\s(?P\/password.*)" +} + +trigger_meta = { + "description": "validates a players password", + "main_function": main_function, + "triggers": [ + { + "identifier": "password (Alloc)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["Allocs"]["chat"] + + triggers["password"] + ), + "callback": main_function + }, + { + "identifier": "password (BCM)", + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["BCM"]["chat"] + + triggers["password"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/permissions/triggers/entered_telnet.py b/bot/modules/permissions/triggers/entered_telnet.py new file mode 100644 index 0000000..afce622 --- /dev/null +++ b/bot/modules/permissions/triggers/entered_telnet.py @@ -0,0 +1,68 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_steamid = regex_result.group("player_steamid") + existing_player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, None) + ) + + if command == "connected": + # we want to mute completely new players and players that are not authenticated on login. + if existing_player_dict is not None: + default_player_password = module.default_options.get("default_player_password", None) + player_is_authenticated = existing_player_dict.get("is_authenticated", False) + if not player_is_authenticated and default_player_password is not None: + is_muted = True + else: + is_muted = False + + event_data = ['set_player_mute', { + 'dataset': module.dom.data.get("module_game_environment", {}).get("active_dataset", None), + 'player_steamid': player_steamid, + 'is_muted': is_muted + }] + module.trigger_action_hook(origin_module, event_data=event_data) + + +trigger_meta = { + "description": "reacts to telnets player discovery messages for real time responses!", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"\[Steamworks.NET\]\s" + r"(?P.*)\s" + r"player:\s(?P.*)\s" + r"SteamId:\s(?P\d+)\s(.*)" + ), + "callback": main_function + }, { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Player\s" + r"(?P.*), " + r"entityid=(?P.*), " + r"name=(?P.*), " + r"steamid=(?P.*), " + r"steamOwner=(?P.*), " + r"ip=(?P.*)" + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/permissions/triggers/player_authentication_change.py b/bot/modules/permissions/triggers/player_authentication_change.py new file mode 100644 index 0000000..b6e0732 --- /dev/null +++ b/bot/modules/permissions/triggers/player_authentication_change.py @@ -0,0 +1,43 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + player_steamid = kwargs.get("dispatchers_steamid", None) + is_authenticated = updated_values_dict.get("is_authenticated", None) + + try: + if all([ + is_authenticated is not None, + player_steamid is not None + ]): + event_data = ['set_player_mute', { + 'dataset': module.dom.data.get("module_game_environment", {}).get("active_dataset", None), + 'player_steamid': player_steamid + }] + + if is_authenticated: + event_data[1]["is_muted"] = False + else: + event_data[1]["is_muted"] = True + + module.trigger_action_hook(module, event_data=event_data) + + except AttributeError: + pass + + +trigger_meta = { + "description": "reacts to a players authentication change", + "main_function": main_function, + "handlers": { + "module_players/elements/%map_identifier%/%steamid%/is_authenticated": main_function, + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/permissions/triggers/player_moved.py b/bot/modules/permissions/triggers/player_moved.py new file mode 100644 index 0000000..b29fab2 --- /dev/null +++ b/bot/modules/permissions/triggers/player_moved.py @@ -0,0 +1,62 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(*args, **kwargs): + module = args[0] + original_values_dict = kwargs.get("original_values_dict", {}) + updated_values_dict = kwargs.get("updated_values_dict", {}) + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + found_lobby = False + for lobby in module.locations.get_elements_by_type("is_lobby"): + lobby_dict = lobby + found_lobby = True + + if found_lobby is False: + return + + # only dive into this when not authenticated + if original_values_dict.get("is_authenticated", False) is False and any([ + original_values_dict.get("pos", {}).get("x") != updated_values_dict.get("pos", {}).get("x"), + original_values_dict.get("pos", {}).get("y") != updated_values_dict.get("pos", {}).get("y"), + original_values_dict.get("pos", {}).get("z") != updated_values_dict.get("pos", {}).get("z") + ]): + on_the_move_player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(updated_values_dict.get("steamid"), {}) + ) + + pos_is_inside_coordinates = module.locations.position_is_inside_boundary(updated_values_dict, lobby_dict) + if pos_is_inside_coordinates is True: + # nothing to do, we are inside the lobby + return + + # no early exits, seems like the player is outside an active lobby without any authentication! + # seems like we should port ^^ + event_data = ['teleport_to_coordinates', { + 'location_coordinates': { + "x": lobby_dict["coordinates"]["x"], + "y": lobby_dict["coordinates"]["y"], + "z": lobby_dict["coordinates"]["z"] + }, + 'steamid': on_the_move_player_dict.get("steamid") + }] + module.trigger_action_hook(module.locations, event_data=event_data) + + +trigger_meta = { + "description": "reacts to every players move!", + "main_function": main_function, + "handlers": { + "module_players/elements/%map_identifier%/%steamid%/pos": main_function, + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/__init__.py b/bot/modules/players/__init__.py new file mode 100644 index 0000000..8b9959a --- /dev/null +++ b/bot/modules/players/__init__.py @@ -0,0 +1,67 @@ +from bot.module import Module +from bot import loaded_modules_dict +from time import time + + +class Players(Module): + dom_element_root = list + dom_element_select_root = list + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:], + "run_observer_interval": 3, + "dom_element_root": [], + "dom_element_select_root": ["selected_by"] + }) + + setattr(self, "required_modules", [ + "module_webserver", + "module_dom", + "module_dom_management", + "module_game_environment", + "module_telnet" + ]) + + self.next_cycle = 0 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_players" + + def on_socket_connect(self, steamid): + Module.on_socket_connect(self, steamid) + + def on_socket_disconnect(self, steamid): + Module.on_socket_disconnect(self, steamid) + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + + self.run_observer_interval = self.options.get( + "run_observer_interval", self.default_options.get("run_observer_interval", None) + ) + self.dom_element_root = self.options.get( + "dom_element_root", self.default_options.get("dom_element_root", None) + ) + self.dom_element_select_root = self.options.get( + "dom_element_select_root", self.default_options.get("dom_element_select_root", None) + ) + # endregion + + def run(self): + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + self.trigger_action_hook(self, event_data=["getadmins", { + "disable_after_success": True + }]) + self.trigger_action_hook(self, event_data=["getplayers", {}]) + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + + +loaded_modules_dict[Players().get_module_identifier()] = Players() diff --git a/bot/modules/players/actions/getadmins.py b/bot/modules/players/actions/getadmins.py new file mode 100644 index 0000000..fa7cd2e --- /dev/null +++ b/bot/modules/players/actions/getadmins.py @@ -0,0 +1,79 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_VERY_SHORT +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_VERY_SHORT + timeout_start = time() + event_data[1]["action_identifier"] = action_name + event_data[1]["fail_reason"] = [] + + if module.telnet.add_telnet_command_to_queue("admin list"): + poll_is_finished = False + # Updated regex for modern 7D2D server format (V 2.x+) + # New format: "Defined User Permissions:" and SteamIDs have "Steam_" prefix + # Timestamps are still present in "Executing command" lines + regex = ( + r"(?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s(?P[-+]?\d*\.\d+|\d+)\s" + r"INF Executing\scommand\s\'admin list\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?PDefined User Permissions:[\s\S]*?(?=Defined Group Permissions:))" + ) + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + event_data[1]["fail_reason"].append("action timed out") + else: + event_data[1]["fail_reason"].append("action already queued up") + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + # Updated regex for modern format with "Steam_" prefix + # Example: " 0: Steam_76561198040658370 (stored name: MOTKU)" + regex = ( + r"(?:^\s{0,7})(?P\d{1,2})\:\s+Steam_(?P\d{17})" + ) + raw_adminlist = match.group("raw_adminlist") + admin_dict = {} + for m in re.finditer(regex, raw_adminlist, re.MULTILINE): + admin_dict[m.group("steamid")] = m.group("level") + + module.dom.data.upsert({ + module.get_module_identifier(): { + "admins": admin_dict + } + }) + + disable_after_success = event_data[1]["disable_after_success"] + if disable_after_success: + module.disable_action(action_name) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "gets a list of all admins and mods", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/getplayers.py b/bot/modules/players/actions/getplayers.py new file mode 100644 index 0000000..42b093a --- /dev/null +++ b/bot/modules/players/actions/getplayers.py @@ -0,0 +1,246 @@ +from bot import loaded_modules_dict +from bot.constants import TELNET_TIMEOUT_SHORT +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def _set_players_offline(players_dict): + """ + Helper function to mark all players in a dictionary as offline. + + Creates a new dictionary with all players set to is_online=False and + is_initialized=False. This is used when telnet commands fail or timeout. + + Args: + players_dict: Dictionary of player data keyed by steam_id + + Returns: + Dictionary with same players but marked as offline + """ + modified_players = {} + for steam_id, player_data in players_dict.items(): + # Create a copy of the player dict + updated_player = player_data.copy() + updated_player["is_online"] = False + updated_player["is_initialized"] = False + modified_players[steam_id] = updated_player + + return modified_players + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = TELNET_TIMEOUT_SHORT + timeout_start = time() + event_data[1]["action_identifier"] = action_name + event_data[1]["fail_reason"] = [] + + if module.telnet.add_telnet_command_to_queue("lp"): + poll_is_finished = False + # Modern format - matches both empty and populated player lists + regex = ( + r"Executing\scommand\s\'lp\'\sby\sTelnet\sfrom\s" + r"(?P.*?)\r?\n" + r"(?P[\s\S]*?)" + r"Total\sof\s(?P\d{1,2})\sin\sthe\sgame" + ) + + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + event_data[1]["fail_reason"].append("timed out waiting for response") + else: + event_data[1]["fail_reason"].append("action already queued up") + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + """ without a place to store this, why bother """ + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_count = int(match.group("player_count")) + if all([ + active_dataset is None, + player_count <= 0 + ]): + return False + + """ get some basic stuff needed later """ + last_seen_gametime_string = module.game_environment.get_last_recorded_gametime_string() + + """ lets extract all data the game provides!! """ + # Note: Modern regex doesn't capture datetime, using current time instead + from datetime import datetime + telnet_datetime = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + raw_playerdata = match.group("raw_playerdata").lstrip() + # Modern 7D2D format includes pltfmid (platform ID) and crossid (Epic cross-platform ID) + # Format: pltfmid=Steam_76561198040658370, crossid=EOS_..., ip=..., ping=... + regex = ( + r"\d{1,2}\. id=(?P\d+), (?P[^,]+), " + r"pos=\((?P-?\d+\.\d+), (?P-?\d+\.\d+), (?P-?\d+\.\d+)\), " + r"rot=\((?P-?\d+\.\d+), (?P-?\d+\.\d+), (?P-?\d+\.\d+)\), " + r"remote=(?P\w+), " + r"health=(?P\d+), " + r"deaths=(?P\d+), " + r"zombies=(?P\d+), " + r"players=(?P\d+), " + r"score=(?P\d+), " + r"level=(?P\d+), " + r"pltfmid=Steam_(?P\d+), crossid=(?P[\w_]+), " + r"ip=(?P[^,]+), " + r"ping=(?P\d+)" + r"\r\n" + ) + + all_players_dict = ( + module.dom.data.get(module.get_module_identifier(), {}) + .get("elements", {}) + .get(active_dataset, {}) + ) + + players_to_update_dict = {} + for m in re.finditer(regex, raw_playerdata): + in_limbo = int(m.group("health")) == 0 + player_dict = { + # data the game provides + "id": m.group("id"), + "name": str(m.group("name")), + "remote": bool(m.group("remote")), + "health": int(m.group("health")), + "deaths": int(m.group("deaths")), + "zombies": int(m.group("zombies")), + "players": int(m.group("players")), + "score": int(m.group("score")), + "level": int(m.group("level")), + "steamid": m.group("steamid"), + "ip": str(m.group("ip")), + "ping": int(float(m.group("ping"))), + "pos": { + "x": int(float(m.group("pos_x"))), + "y": int(float(m.group("pos_y"))), + "z": int(float(m.group("pos_z"))) + }, + "rot": { + "x": int(float(m.group("rot_x"))), + "y": int(float(m.group("rot_y"))), + "z": int(float(m.group("rot_z"))) + }, + # data invented by the bot + "dataset": active_dataset, + "in_limbo": in_limbo, + "is_online": True, + "is_initialized": True, + "last_updated_servertime": telnet_datetime, + "last_seen_gametime": last_seen_gametime_string, + "owner": m.group("steamid") + } + players_to_update_dict[m.group("steamid")] = player_dict + + """ players_to_update_dict now holds all game-data for all online players plus a few generated ones like last seen + and is_initialized. Otherwise it's empty """ + + # set all players not currently online to offline + online_players_list = list(players_to_update_dict.keys()) + for steamid, existing_player_dict in all_players_dict.items(): + if existing_player_dict["is_initialized"] is False: + continue + + if steamid not in online_players_list and existing_player_dict["is_online"] is True: + # Create offline version of player using copy + updated_player = existing_player_dict.copy() + updated_player["is_online"] = False + updated_player["is_initialized"] = False + players_to_update_dict[steamid] = updated_player + + if len(players_to_update_dict) >= 1: + module.dom.data.upsert({ + module.get_module_identifier(): { + "elements": { + active_dataset: players_to_update_dict + } + } + }) + + if online_players_list != module.dom.data.get(module.get_module_identifier(), {}).get("online_players"): + module.dom.data.upsert({ + module.get_module_identifier(): { + "online_players": online_players_list + } + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + return + + all_existing_players_dict = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("elements", {}) + .get(active_dataset, {}) + ) + + # Mark all existing players as offline using helper function + all_modified_players_dict = _set_players_offline(all_existing_players_dict) + + module.dom.data.upsert({ + module.get_module_identifier(): { + "elements": { + active_dataset: all_modified_players_dict + } + } + }) + + module.dom.data.upsert({ + module.get_module_identifier(): { + "online_players": [] + } + }) + + +def skip_it(module, event_data, dispatchers_steamid=None): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + if active_dataset is None: + return + + all_existing_players_dict = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("elements", {}) + .get(active_dataset, {}) + ) + + # Mark all existing players as offline using helper function + all_modified_players_dict = _set_players_offline(all_existing_players_dict) + + module.dom.data.upsert({ + module.get_module_identifier(): { + "elements": { + active_dataset: all_modified_players_dict + } + } + }) + + +action_meta = { + "description": "gets a list of all currently logged in players and sets status-flags", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "skip_it": skip_it, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/kick_player.py b/bot/modules/players/actions/kick_player.py new file mode 100644 index 0000000..5037bc0 --- /dev/null +++ b/bot/modules/players/actions/kick_player.py @@ -0,0 +1,107 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from bot.logger import get_logger +from os import path, pardir +from time import time, sleep +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] +logger = get_logger("players.kick_player") + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + action_is_confirmed = event_data[1].get("confirmed", "False") + player_to_be_kicked = event_data[1].get("steamid", None) + + if action == "kick_player": + if action_is_confirmed == "True": + timeout = 5 # [seconds] + timeout_start = time() + + reason = event_data[1].get("reason") + + # Get player entity ID - game requires entity ID instead of steamid + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(player_to_be_kicked, {}) + ) + player_entity_id = player_dict.get("id") + + if not player_entity_id: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + command = "kick {} \"{}\"".format(player_entity_id, reason) + """ + i was trying re.escape, string replacements... the only thing that seems to work is all of them together + Had some big trouble filtering out stuff like ^ and " and whatnot + """ + regex = ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Executing\scommand\s\'" + re.escape(command) + r"\'\s" + r"by\sTelnet\s" + r"from\s(?P.*)" + ).replace('"', '\\"') + + logger.debug("kick_command_prepared", + command=command, + user=dispatchers_steamid, + target=player_to_be_kicked, + reason=reason) + + if not module.telnet.add_telnet_command_to_queue(command): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + else: + module.set_current_view(dispatchers_steamid, { + "current_view": "kick-modal", + "current_view_steamid": player_to_be_kicked + }) + return + + elif action == "cancel_kick_player": + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + module.set_current_view(dispatchers_steamid, { + "current_view": "frontend", + }) + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "kicks a player", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/say_to_player.py b/bot/modules/players/actions/say_to_player.py new file mode 100644 index 0000000..e8c8c83 --- /dev/null +++ b/bot/modules/players/actions/say_to_player.py @@ -0,0 +1,74 @@ +from bot import loaded_modules_dict, telnet_prefixes +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = 5 # [seconds] + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + target_player_steamid = event_data[1].get("steamid", None) + message = event_data[1].get("message", None) + + # Get player entity ID - game requires entity ID instead of steamid + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(target_player_steamid, {}) + ) + player_entity_id = player_dict.get("id") + + if not player_entity_id: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + command = "sayplayer {} \"{}\"".format(player_entity_id, message) + if not module.telnet.add_telnet_command_to_queue(command): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format: timestamps ARE present in "Executing command" lines + regex = ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Executing\scommand\s\'" + command + r"\'\sby\sTelnet\sfrom\s(?P.*)" + ) + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "sends a message to any player", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/teleport_player.py b/bot/modules/players/actions/teleport_player.py new file mode 100644 index 0000000..eaa43fb --- /dev/null +++ b/bot/modules/players/actions/teleport_player.py @@ -0,0 +1,114 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from bot.logger import get_logger +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] +logger = get_logger("players.teleport_player") + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = 6 # [seconds] + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + target_coordinates = event_data[1].get("coordinates", None) + player_to_be_teleported_steamid = event_data[1].get("steamid", None) + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_to_be_teleported_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(player_to_be_teleported_steamid, {}) + ) + player_coordinates = player_to_be_teleported_dict.get("pos", None) + + if all([ + dataset is not None, + target_coordinates is not None, + player_coordinates is not None + ]) and all([ + # no sense in porting a player to a place they are already standing on ^^ + target_coordinates != player_coordinates + ]): + # Use entity ID instead of steamid - game requires entity ID now + player_entity_id = player_to_be_teleported_dict.get("id") + if not player_entity_id: + event_data[1]["fail_reason"] = "player entity ID not found" + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + command = ( + "teleportplayer {player_to_be_teleported} {pos_x} {pos_y} {pos_z}" + ).format( + player_to_be_teleported=player_entity_id, + pos_x=int(float(target_coordinates["x"])), + pos_y=int(float(target_coordinates["y"])), + pos_z=int(float(target_coordinates["z"])) + ) + + if not module.telnet.add_telnet_command_to_queue(command): + event_data[1]["fail_reason"] = "duplicate command" + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + regex = ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"PlayerSpawnedInWorld\s" + r"\(" + r"reason: (?P.+?),\s" + r"position: (?P.*),\s(?P.*),\s(?P.*)" + r"\):\s" + r"EntityID={entity_id},\s".format(entity_id=player_to_be_teleported_dict.get("id")) + + r"PlayerID='{player_to_be_teleported}',\s".format(player_to_be_teleported=player_to_be_teleported_steamid) + + r"OwnerID='{player_to_be_teleported}',\s".format(player_to_be_teleported=player_to_be_teleported_steamid) + + r"PlayerName='(?P.*)'" + ) + + while not poll_is_finished and (time() < timeout_start + timeout): + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + sleep(0.25) + + event_data[1]["fail_reason"] = "action timed out" + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + event_data[1]["fail_reason"] = "insufficient data for execution" + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + player_to_be_teleported = event_data[1].get("steamid", None) + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + +def callback_fail(module, event_data, dispatchers_steamid): + logger.error("teleport_failed", + user=dispatchers_steamid, + target=event_data[1].get("steamid"), + reason=event_data[1].get("fail_reason", "no reason known")) + + +action_meta = { + "description": "teleports a player", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/toggle_player_authentication.py b/bot/modules/players/actions/toggle_player_authentication.py new file mode 100644 index 0000000..a765d4c --- /dev/null +++ b/bot/modules/players/actions/toggle_player_authentication.py @@ -0,0 +1,50 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + event_data[1]["action_identifier"] = action_name + + target_player_steamid = event_data[1].get("steamid", None) + auth_status = event_data[1].get("auth_status", None) + active_dataset = event_data[1].get("dataset", None) + + if all([target_player_steamid, auth_status is not None, active_dataset]): + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + target_player_steamid: { + "is_authenticated": auth_status + } + } + } + } + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "toggles a player's authentication status", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/toggle_player_mute.py b/bot/modules/players/actions/toggle_player_mute.py new file mode 100644 index 0000000..99d260d --- /dev/null +++ b/bot/modules/players/actions/toggle_player_mute.py @@ -0,0 +1,91 @@ +from bot import loaded_modules_dict, telnet_prefixes +from os import path, pardir +from time import sleep, time +import re + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + timeout = 5 # [seconds] + timeout_start = time() + event_data[1]["action_identifier"] = action_name + + target_player_steamid = event_data[1].get("steamid", None) + mute_status = event_data[1].get("mute_status", None) + + # Get player entity ID - game requires entity ID instead of steamid + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(target_player_steamid, {}) + ) + player_entity_id = player_dict.get("id") + + if not player_entity_id: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + command = "bc-mute {} {}".format(player_entity_id, mute_status) + + if not module.telnet.add_telnet_command_to_queue(command): + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + poll_is_finished = False + # Modern format: timestamps ARE present in "Executing command" lines + regex = ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Executing\scommand\s\'" + command + r"\'\sby\sTelnet\sfrom\s(?P.*)" + ) + while not poll_is_finished and (time() < timeout_start + timeout): + sleep(0.25) + match = False + for match in re.finditer(regex, module.telnet.telnet_buffer, re.DOTALL): + poll_is_finished = True + + if match: + module.callback_success(callback_success, module, event_data, dispatchers_steamid, match) + return + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + target_player_steamid = event_data[1].get("steamid", None) + flag_player_to_be_muted = event_data[1].get("mute_status", None) + active_dataset = event_data[1].get("dataset", None) + + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + target_player_steamid: { + "is_muted": flag_player_to_be_muted + } + } + } + } + }) + + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "mutes or unmutes a given player", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/toggle_players_widget_view.py b/bot/modules/players/actions/toggle_players_widget_view.py new file mode 100644 index 0000000..c4102a8 --- /dev/null +++ b/bot/modules/players/actions/toggle_players_widget_view.py @@ -0,0 +1,50 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + player_steamid = event_data[1].get("steamid", None) + + if action == "show_options": + current_view = "options" + current_view_steamid = None + elif action == "show_frontend": + current_view = "frontend" + current_view_steamid = None + elif action == "show_info_view": + current_view = "info" + current_view_steamid = player_steamid + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + module.set_current_view(dispatchers_steamid, { + "current_view": current_view, + "current_view_steamid": current_view_steamid + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "manages player entries", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/actions/update_player_permission_level.py b/bot/modules/players/actions/update_player_permission_level.py new file mode 100644 index 0000000..28bc3a6 --- /dev/null +++ b/bot/modules/players/actions/update_player_permission_level.py @@ -0,0 +1,64 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid=None): + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_to_be_updated_steamid = event_data[1].get("steamid", None) + permission_level = event_data[1].get("level", 1000) + + event_data[1]["action_identifier"] = action_name + event_data[1]["fail_reason"] = [] + + player_to_be_updated = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_to_be_updated_steamid, None) + ) + + if player_to_be_updated is not None: + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + player_to_be_updated_steamid: { + "permission_level": permission_level + } + } + } + } + }) + + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + return + else: + event_data[1]["fail_reason"].append( + "player does not exist on this server / has not logged in yet t create a file" + ) + + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "updates a players profiles permission data", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": True, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/players/templates/jinja2_macros.html b/bot/modules/players/templates/jinja2_macros.html new file mode 100644 index 0000000..3aea3eb --- /dev/null +++ b/bot/modules/players/templates/jinja2_macros.html @@ -0,0 +1,20 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ active_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- else -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ inactive_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- endif -%} +{%- endmacro -%} \ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/control_info_link.html b/bot/modules/players/templates/manage_players_widget/control_info_link.html new file mode 100644 index 0000000..dbe0600 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/control_info_link.html @@ -0,0 +1,10 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + +{{- construct_toggle_link( + True, + "i", ['widget_event', ['players', ['toggle_players_widget_view', { + 'steamid': player.steamid, + 'action': 'show_info_view' + }]]] +)-}} + \ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/control_kick_link.html b/bot/modules/players/templates/manage_players_widget/control_kick_link.html new file mode 100644 index 0000000..60ff0b3 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/control_kick_link.html @@ -0,0 +1,13 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +{%- if player.is_initialized == true -%} + +{{- construct_toggle_link( + player.is_initialized, + "kick", ['widget_event', ['players', ['kick_player', { + 'action': 'kick_player', + 'steamid': player.steamid, + 'confirmed': 'False' + }]]] +)-}} + +{%- endif -%} \ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/control_switch_options_view.html b/bot/modules/players/templates/manage_players_widget/control_switch_options_view.html new file mode 100644 index 0000000..3199b76 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/control_switch_options_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + options_view_toggle, + "options", ['widget_event', ['players', ['toggle_players_widget_view', {'steamid': steamid, "action": "show_options"}]]], + "back", ['widget_event', ['players', ['toggle_players_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/players/templates/manage_players_widget/control_switch_view.html b/bot/modules/players/templates/manage_players_widget/control_switch_view.html new file mode 100644 index 0000000..fd82e2b --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/control_switch_view.html @@ -0,0 +1,3 @@ +
+ {{ control_switch_options_view }} +
\ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/modal_confirm_kick.html b/bot/modules/players/templates/manage_players_widget/modal_confirm_kick.html new file mode 100644 index 0000000..8bb7202 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/modal_confirm_kick.html @@ -0,0 +1,43 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + + + + + + + + + + + + + + + +
+

Are you sure you want to kick player {{ steamid }}?

+
+

By clicking [confirm] you will continue to proceed kicking the dude.

+
+

Clicking [cancel] will abort the kicking.

+
+
+ +
+
+ + + +
\ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/modal_confirm_kick_send_data.html b/bot/modules/players/templates/manage_players_widget/modal_confirm_kick_send_data.html new file mode 100644 index 0000000..b174f7f --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/modal_confirm_kick_send_data.html @@ -0,0 +1,15 @@ + +confirm diff --git a/bot/modules/players/templates/manage_players_widget/table_footer.html b/bot/modules/players/templates/manage_players_widget/table_footer.html new file mode 100644 index 0000000..92d0626 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/table_footer.html @@ -0,0 +1,7 @@ + + +
+ {{ action_delete_button }} +
+ + \ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/table_header.html b/bot/modules/players/templates/manage_players_widget/table_header.html new file mode 100644 index 0000000..0239556 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/table_header.html @@ -0,0 +1,13 @@ + + * + actions + level + name + health + id + steamid + pos + zombies + last seen + gametime + \ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/table_row.html b/bot/modules/players/templates/manage_players_widget/table_row.html new file mode 100644 index 0000000..861d651 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/table_row.html @@ -0,0 +1,30 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} + + + {{ control_select_link }} + + {{ control_info_link }}{{ control_kick_link }} + {{ player.level }} + {{ player.name }} + {{ player.health }} + {{ player.id }} + {{ player.steamid }} + + + {{ ((player | default({})).pos | default({}) ).x | default('0') }} + + + {{ ((player | default({})).pos | default({}) ).y | default('0') }} + + + {{ ((player | default({})).pos | default({}) ).z | default('0') }} + + + {{ player.zombies }} + + {{ player.last_updated_servertime }} + + + {{ player.last_seen_gametime }} + + diff --git a/bot/modules/players/templates/manage_players_widget/view_frontend.html b/bot/modules/players/templates/manage_players_widget/view_frontend.html new file mode 100644 index 0000000..c0a1641 --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/view_frontend.html @@ -0,0 +1,33 @@ +
+
+ Players +
+
+ +
+ + + + {{ table_header }} + + + {{ table_rows }} + + + {{ table_footer }} + +
+ offline + offline and dead + logging in + online + online and dead +
+
+ +
+
diff --git a/bot/modules/players/templates/manage_players_widget/view_info.html b/bot/modules/players/templates/manage_players_widget/view_info.html new file mode 100644 index 0000000..30e840b --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/view_info.html @@ -0,0 +1,155 @@ +
+
+ Players +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
player-info ({{ player.steamid }})
Name{{ player.name }}the players steam-name
ID{{ player.id }}
SteamID{{ player.steamid }}
Health{{ player.health }}
Position + {{ ((player | default({})).pos | default({}) ).x | default('0') }} + {{ ((player | default({})).pos | default({}) ).y | default('0') }} + {{ ((player | default({})).pos | default({}) ).z | default('0') }} +
Rotation + {{ ((player | default({})).rot | default({}) ).x | default('0') }} + {{ ((player | default({})).rot | default({}) ).y | default('0') }} + {{ ((player | default({})).rot | default({}) ).z | default('0') }} +
Level{{ player.level }}
IP-Address{{ player.ip }}
Ping{{ player.ping }}
Deaths{{ player.deaths }}
Zombie-Kills{{ player.zombies }}
Player-Kills{{ player.players }}
Score{{ player.score }}
Last seen + {{ player.last_updated_servertime }} +
First seen (gametime) + {{ player.first_seen_gametime }} +
Last seen (gametime) + {{ player.last_seen_gametime }} +
dataset + {{ player.dataset }} + the server-instance this entry is from
is_authenticated + {{ player.is_authenticated | default("False") }} + has authenticated with the bot
in_limbo + {{ player.in_limbo | default("False") }} + hasn't got any health, is dead(ish)!
is_initialized + {{ player.is_initialized | default("False") }} + player is online, has health, is ready to go!
is_online + {{ player.is_online | default("False") }} + we can see you!
is_muted + {{ player.is_muted | default("False") }} + come again?
+
\ No newline at end of file diff --git a/bot/modules/players/templates/manage_players_widget/view_options.html b/bot/modules/players/templates/manage_players_widget/view_options.html new file mode 100644 index 0000000..3b051ec --- /dev/null +++ b/bot/modules/players/templates/manage_players_widget/view_options.html @@ -0,0 +1,27 @@ +
+
+ Players +
+
+ +
+ + + + + + + + + + + {% for key, value in widget_options.items() %} + + + + {% endfor %} + +
player table widget options
widget-options
{{key}}{{value}}
+
\ No newline at end of file diff --git a/bot/modules/players/templates/webmap/player_actions.html b/bot/modules/players/templates/webmap/player_actions.html new file mode 100644 index 0000000..cd4c178 --- /dev/null +++ b/bot/modules/players/templates/webmap/player_actions.html @@ -0,0 +1,168 @@ +// ======================================== +// Player Popup Actions +// ======================================== + +// Kick player from map popup +window.kickPlayerFromMap = function(steamid, playerName) { + const reason = prompt('Kick reason for ' + playerName + ':', 'Admin action'); + if (reason === null) { + return; // User cancelled + } + + window.socket.emit( + 'widget_event', + ['players', + ['kick_player', { + 'action': 'kick_player', + 'steamid': steamid, + 'reason': reason || 'No reason provided', + 'confirmed': 'True' + }]] + ); + console.log('[MAP] Kicking player:', steamid, 'Reason:', reason); +}; + +// Send message to player from map popup +window.messagePlayerFromMap = function(steamid, playerName) { + const message = prompt('Message to ' + playerName + ':', ''); + if (!message) { + return; // User cancelled or empty message + } + + window.socket.emit( + 'widget_event', + ['players', + ['say_to_player', { + 'steamid': steamid, + 'message': message + }]] + ); + console.log('[MAP] Sending message to player:', steamid, 'Message:', message); +}; + +// Toggle player mute status from map popup +window.togglePlayerMuteFromMap = function(steamid, dataset, isMuted) { + window.socket.emit( + 'widget_event', + ['players', + ['toggle_player_mute', { + 'steamid': steamid, + 'dataset': dataset, + 'mute_status': isMuted + }]] + ); + console.log('[MAP] Toggling player mute status:', steamid, 'to', isMuted); +}; + +// Toggle player authentication status from map popup +window.togglePlayerAuthFromMap = function(steamid, dataset, isAuth) { + window.socket.emit( + 'widget_event', + ['players', + ['toggle_player_authentication', { + 'steamid': steamid, + 'dataset': dataset, + 'auth_status': isAuth + }]] + ); + console.log('[MAP] Toggling player auth status:', steamid, 'to', isAuth); +}; + +// Teleport player from map popup - click to select destination +window.teleportPlayerFromMap = function(steamid, playerName) { + // Close any open popups + map.closePopup(); + + // Create info message + const infoDiv = L.DomUtil.create('div', 'coordinates-display'); + infoDiv.style.bottom = '50px'; + infoDiv.style.background = 'rgba(255, 204, 0, 0.95)'; + infoDiv.style.borderColor = 'var(--lcars-golden-tanoi)'; + infoDiv.style.color = '#000'; + infoDiv.style.fontWeight = 'bold'; + infoDiv.innerHTML = '🎯 Click destination for ' + playerName + ' (Locations will use their TP entry)'; + document.getElementById('map').appendChild(infoDiv); + + // Change cursor + map.getContainer().style.cursor = 'crosshair'; + + // Wait for click + map.once('click', function(e) { + const clickCoords = e.latlng; + let targetX = Math.round(clickCoords.lat); + let targetY = 0; // Default Y, will be adjusted + let targetZ = Math.round(clickCoords.lng); + + // Check if click is inside any location with teleport_entry + let foundLocationTeleport = false; + for (const locationId in locations) { + const loc = locations[locationId]; + const teleportEntry = loc.teleport_entry || {}; + const hasTeleport = teleportEntry.x !== undefined && + teleportEntry.y !== undefined && + teleportEntry.z !== undefined; + + if (hasTeleport) { + // Check if click is inside this location's bounds + if (isInsideLocation(clickCoords.lat, clickCoords.lng, loc)) { + // Use location's teleport entry + targetX = Math.round(parseFloat(teleportEntry.x)); + targetY = Math.round(parseFloat(teleportEntry.y)); + targetZ = Math.round(parseFloat(teleportEntry.z)); + foundLocationTeleport = true; + console.log('[MAP] Using location teleport entry:', loc.name, 'at', targetX, targetY, targetZ); + break; + } + } + } + + // If no location teleport found, use default Y (ground level) + if (!foundLocationTeleport) { + targetY = -1; // Standard ground level + } + + // Call teleport_player action + window.socket.emit( + 'widget_event', + ['players', + ['teleport_player', { + 'steamid': steamid, + 'coordinates': { + 'x': targetX.toString(), + 'y': targetY.toString(), + 'z': targetZ.toString() + } + }]] + ); + + console.log('[MAP] Teleporting player:', steamid, 'to', targetX, targetY, targetZ); + + // Cleanup + map.getContainer().style.cursor = ''; + document.getElementById('map').removeChild(infoDiv); + }); +}; + +// Helper function to check if coordinates are inside a location +function isInsideLocation(x, z, loc) { + const coords = loc.coordinates; + const dims = loc.dimensions; + const shape = loc.shape; + + if (shape === 'circle' || shape === 'spherical') { + const radius = parseFloat(dims.radius || 0); + const distance = Math.sqrt( + Math.pow(x - coords.x, 2) + + Math.pow(z - coords.z, 2) + ); + return distance <= radius; + } else if (shape === 'rectangle' || shape === 'box') { + const width = parseFloat(dims.width || 0); + const length = parseFloat(dims.length || 0); + return ( + x >= coords.x - width && x <= coords.x + width && + z >= coords.z - length && z <= coords.z + length + ); + } + return false; +} diff --git a/bot/modules/players/templates/webmap/player_markers.html b/bot/modules/players/templates/webmap/player_markers.html new file mode 100644 index 0000000..11a7619 --- /dev/null +++ b/bot/modules/players/templates/webmap/player_markers.html @@ -0,0 +1,3 @@ +// Player markers are now loaded dynamically via Socket.IO +// Initial loading is handled by player_position_update events +// See player_update_handler.html for marker creation logic diff --git a/bot/modules/players/templates/webmap/player_popup.html b/bot/modules/players/templates/webmap/player_popup.html new file mode 100644 index 0000000..8d619e0 --- /dev/null +++ b/bot/modules/players/templates/webmap/player_popup.html @@ -0,0 +1,64 @@ +// Helper function to create player popup content +function createPlayerPopup(steamid, player) { + const healthMax = 150; + const healthPercent = Math.round((player.health / healthMax) * 100); + + // Status badge + let statusBadge = '🟢 Online'; + let statusColor = '#66ff66'; + if (player.in_limbo) { + statusBadge = '💀 Dead'; + statusColor = '#ff6666'; + } else if (!player.is_initialized) { + statusBadge = '🔄 Spawning'; + statusColor = '#ffaa66'; + } + + // Permission badge + let permissionBadge = ''; + if (player.permission_level === 0) { + permissionBadge = '🛡️ ADMIN'; + } + + // Use template literal for clean HTML + return ` +
+ ${player.name} + ${permissionBadge ? '
' + permissionBadge : ''} +
${statusBadge} +

+ ❤️ Health: ${player.health}/${healthMax} (${healthPercent}%) +
⭐ Level: ${player.level} | 🎯 Score: ${player.score} +
🧟 Zombies: ${player.zombies} | 💀 Deaths: ${player.deaths} +
👥 Players: ${player.players} | 📡 Ping: ${player.ping}ms +

+
+ + +
+
+ + +
+ +
+ `; +} diff --git a/bot/modules/players/templates/webmap/player_update_handler.html b/bot/modules/players/templates/webmap/player_update_handler.html new file mode 100644 index 0000000..025c8e3 --- /dev/null +++ b/bot/modules/players/templates/webmap/player_update_handler.html @@ -0,0 +1,58 @@ +// Handle player position updates +if (data.data_type === 'player_position_update' && data.payload) { + const playerData = data.payload; + const steamid = playerData.steamid; + const pos = playerData.position; + + console.log('[MAP] Processing player update for:', steamid, pos); + + if (!steamid || !pos) { + console.warn('[MAP] Invalid player update data:', playerData); + return; + } + + // Update or create player data in players object + if (!players[steamid]) { + players[steamid] = {}; + } + + // Update all player data + players[steamid].pos = pos; + players[steamid].name = playerData.name || players[steamid].name || 'Player'; + players[steamid].level = playerData.level !== undefined ? playerData.level : (players[steamid].level || 0); + players[steamid].health = playerData.health !== undefined ? playerData.health : (players[steamid].health || 0); + players[steamid].zombies = playerData.zombies !== undefined ? playerData.zombies : (players[steamid].zombies || 0); + players[steamid].deaths = playerData.deaths !== undefined ? playerData.deaths : (players[steamid].deaths || 0); + players[steamid].players = playerData.players !== undefined ? playerData.players : (players[steamid].players || 0); + players[steamid].score = playerData.score !== undefined ? playerData.score : (players[steamid].score || 0); + players[steamid].ping = playerData.ping !== undefined ? playerData.ping : (players[steamid].ping || 0); + players[steamid].is_muted = playerData.is_muted !== undefined ? playerData.is_muted : (players[steamid].is_muted || false); + players[steamid].is_authenticated = playerData.is_authenticated !== undefined ? playerData.is_authenticated : (players[steamid].is_authenticated || false); + players[steamid].in_limbo = playerData.in_limbo !== undefined ? playerData.in_limbo : (players[steamid].in_limbo || false); + players[steamid].is_initialized = playerData.is_initialized !== undefined ? playerData.is_initialized : (players[steamid].is_initialized || false); + players[steamid].permission_level = playerData.permission_level || players[steamid].permission_level || null; + players[steamid].dataset = playerData.dataset || players[steamid].dataset || ''; + + // Update or create marker + if (playerMarkers[steamid]) { + // Update existing marker position and popup + playerMarkers[steamid].setLatLng([pos.x, pos.z]); + playerMarkers[steamid].setPopupContent(createPlayerPopup(steamid, players[steamid])); + console.log('[MAP] Updated player marker:', steamid); + } else { + // Create new marker + const marker = L.circleMarker([pos.x, pos.z], { + radius: 5, + fillColor: '#66ccff', + color: '#0099ff', + weight: 2, + opacity: 1, + fillOpacity: 0.9, + className: 'player-marker' + }).addTo(map); + + marker.bindPopup(createPlayerPopup(steamid, players[steamid])); + playerMarkers[steamid] = marker; + console.log('[MAP] Created new player marker:', steamid); + } +} diff --git a/bot/modules/players/triggers/admins_updated.py b/bot/modules/players/triggers/admins_updated.py new file mode 100644 index 0000000..2c9d024 --- /dev/null +++ b/bot/modules/players/triggers/admins_updated.py @@ -0,0 +1,36 @@ +from .discord_webhook import DiscordWebhook +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(*args, **kwargs): + module = args[0] + + permission_levels = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("admins", {}) + ) + + for steamid, level in permission_levels.items(): + event_data = ['update_player_permission_level', { + 'steamid': steamid, + 'level': level + }] + module.trigger_action_hook(module.players, event_data=event_data) + + +trigger_meta = { + "description": ( + "Will call the update_player_permission_level action after permissions have been retrieved from the game" + ), + "main_function": main_function, + "handlers": { + "module_players/admins": main_function, + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/triggers/discord_webhook/__init__.py b/bot/modules/players/triggers/discord_webhook/__init__.py new file mode 100644 index 0000000..0213ff1 --- /dev/null +++ b/bot/modules/players/triggers/discord_webhook/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["DiscordWebhook", + "DiscordEmbed"] + +from .webhook import (DiscordWebhook, DiscordEmbed) diff --git a/bot/modules/players/triggers/discord_webhook/webhook.py b/bot/modules/players/triggers/discord_webhook/webhook.py new file mode 100644 index 0000000..4486888 --- /dev/null +++ b/bot/modules/players/triggers/discord_webhook/webhook.py @@ -0,0 +1,284 @@ +import requests +import time +import datetime +import logging +import json + +logger = logging.getLogger(__name__) + + +class DiscordWebhook: + """ + Webhook for Discord + """ + def __init__(self, url, **kwargs): + """ + Init Webhook for Discord + :param url: discord_webhook webhook url + :keyword content: the message contents + :keyword username: override the default username of the webhook + :keyword avatar_url: ooverride the default avatar of the webhook + :keyword tts: true if this is a TTS message + :keyword file: file contents + :keyword filename: file name + :keyword embeds: list of embedded rich content + :keyword proxies: dict of proxies + """ + self.url = url + self.content = kwargs.get('content') + self.username = kwargs.get('username') + self.avatar_url = kwargs.get('avatar_url') + self.tts = kwargs.get('tts', False) + self.files = kwargs.get('files', dict()) + self.embeds = kwargs.get('embeds', []) + self.proxies = kwargs.get('proxies', None) + + def add_file(self, file, filename): + """ + add file to webhook + :param file: file content + :param filename: filename + :return: + """ + self.files['_{}'.format(filename)] = (filename, file) + + def add_embed(self, embed): + """ + add embedded rich content + :param embed: embed object or dict + """ + self.embeds.append(embed.__dict__ if isinstance(embed, DiscordEmbed) else embed) + + def remove_embed(self, index): + """ + remove embedded rich content from `self.embeds` + :param index: index of embed in `self.embeds` + """ + self.embeds.pop(index) + + def get_embeds(self): + """ + get all `self.embeds` as list + :return: `self.embeds` + """ + return self.embeds + + def set_proxies(self, proxies): + """ + set proxies + :param proxies: dict of proxies + """ + self.proxies = proxies + + @property + def json(self): + """ + convert webhook data to json + :return webhook data as json: + """ + data = dict() + embeds = self.embeds + self.embeds = list() + # convert DiscordEmbed to dict + for embed in embeds: + self.add_embed(embed) + for key, value in self.__dict__.items(): + if value and key not in ['url', 'files', 'filename']: + data[key] = value + embeds_empty = all(not embed for embed in data["embeds"]) if 'embeds' in data else True + if embeds_empty and 'content' not in data and bool(self.files) is False: + logger.error('webhook message is empty! set content or embed data') + return data + + def execute(self): + """ + execute Webhook + :return: + """ + if bool(self.files) is False: + response = requests.post(self.url, json=self.json, proxies=self.proxies) + else: + self.files['payload_json'] = (None, json.dumps(self.json)) + response = requests.post(self.url, files=self.files, proxies=self.proxies) + if response.status_code in [200, 204]: + logger.debug("Webhook executed") + else: + logger.error('status code %s: %s' % (response.status_code, response.content.decode("utf-8"))) + + +class DiscordEmbed: + """ + Discord Embed + """ + def __init__(self, **kwargs): + """ + Init Discord Embed + :keyword title: title of embed + :keyword description: description of embed + :keyword url: url of embed + :keyword timestamp: timestamp of embed content + :keyword color: color code of the embed as int + :keyword footer: footer information + :keyword image: image information + :keyword thumbnail: thumbnail information + :keyword video: video information + :keyword provider: provider information + :keyword author: author information + :keyword fields: fields information + """ + self.title = kwargs.get('title') + self.description = kwargs.get('description') + self.url = kwargs.get('url') + self.timestamp = kwargs.get('timestamp') + self.color = kwargs.get('color') + self.footer = kwargs.get('footer') + self.image = kwargs.get('image') + self.thumbnail = kwargs.get('thumbnail') + self.video = kwargs.get('video') + self.provider = kwargs.get('provider') + self.author = kwargs.get('author') + self.fields = kwargs.get('fields', []) + + def set_title(self, title): + """ + set title of embed + :param title: title of embed + """ + self.title = title + + def set_description(self, description): + """ + set description of embed + :param description: description of embed + """ + self.description = description + + def set_url(self, url): + """ + set url of embed + :param url: url of embed + """ + self.url = url + + def set_timestamp(self, timestamp=str(datetime.datetime.utcfromtimestamp(time.time()))): + """ + set timestamp of embed content + :param timestamp: (optional) timestamp of embed content + """ + self.timestamp = timestamp + + def set_color(self, color): + """ + set color code of the embed as int + :param color: color code of the embed as int + """ + self.color = color + + def set_footer(self, **kwargs): + """ + set footer information of embed + :keyword text: footer text + :keyword icon_url: url of footer icon (only supports http(s) and attachments) + :keyword proxy_icon_url: a proxied url of footer icon + """ + self.footer = { + 'text': kwargs.get('text'), + 'icon_url': kwargs.get('icon_url'), + 'proxy_icon_url': kwargs.get('proxy_icon_url') + } + + def set_image(self, **kwargs): + """ + set image of embed + :keyword url: source url of image (only supports http(s) and attachments) + :keyword proxy_url: a proxied url of the image + :keyword height: height of image + :keyword width: width of image + """ + self.image = { + 'url': kwargs.get('url'), + 'proxy_url': kwargs.get('proxy_url'), + 'height': kwargs.get('height'), + 'width': kwargs.get('width'), + } + + def set_thumbnail(self, **kwargs): + """ + set thumbnail of embed + :keyword url: source url of thumbnail (only supports http(s) and attachments) + :keyword proxy_url: a proxied thumbnail of the image + :keyword height: height of thumbnail + :keyword width: width of thumbnail + """ + self.thumbnail = { + 'url': kwargs.get('url'), + 'proxy_url': kwargs.get('proxy_url'), + 'height': kwargs.get('height'), + 'width': kwargs.get('width'), + } + + def set_video(self, **kwargs): + """ + set video of embed + :keyword url: source url of video + :keyword height: height of video + :keyword width: width of video + """ + self.video = { + 'url': kwargs.get('url'), + 'height': kwargs.get('height'), + 'width': kwargs.get('width'), + } + + def set_provider(self, **kwargs): + """ + set provider of embed + :keyword name: name of provider + :keyword url: url of provider + """ + self.provider = { + 'name': kwargs.get('name'), + 'url': kwargs.get('url'), + } + + def set_author(self, **kwargs): + """ + set author of embed + :keyword name: name of author + :keyword url: url of author + :keyword icon_url: url of author icon (only supports http(s) and attachments) + :keyword proxy_icon_url: a proxied url of author icon + """ + self.author = { + 'name': kwargs.get('name'), + 'url': kwargs.get('url'), + 'icon_url': kwargs.get('icon_url'), + 'proxy_icon_url': kwargs.get('proxy_icon_url'), + } + + def add_embed_field(self, **kwargs): + """ + set field of embed + :keyword name: name of the field + :keyword value: value of the field + :keyword inline: (optional) whether or not this field should display inline + """ + self.fields.append({ + 'name': kwargs.get('name'), + 'value': kwargs.get('value'), + 'inline': kwargs.get('inline', True) + }) + + def del_embed_field(self, index): + """ + remove field from `self.fields` + :param index: index of field in `self.fields` + """ + self.fields.pop(index) + + def get_embed_fields(self): + """ + get all `self.fields` as list + :return: `self.fields` + """ + return self.fields diff --git a/bot/modules/players/triggers/entered_telnet.py b/bot/modules/players/triggers/entered_telnet.py new file mode 100644 index 0000000..740ab11 --- /dev/null +++ b/bot/modules/players/triggers/entered_telnet.py @@ -0,0 +1,115 @@ +from .discord_webhook import DiscordWebhook +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_steamid = regex_result.group("player_steamid") + existing_player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, None) + ) + + last_seen_gametime_string = module.game_environment.get_last_recorded_gametime_string() + + executed_trigger = False + player_dict = {} + if command in ["Authenticating", "connected"]: + if existing_player_dict is None: + player_dict = { + "name": regex_result.group("player_name"), + "steamid": player_steamid, + "pos": { + "x": 0, + "y": 0, + "z": 0, + }, + "dataset": active_dataset, + "owner": player_steamid, + "last_seen_gametime": last_seen_gametime_string + } + else: + player_dict.update(existing_player_dict) + + player_dict.update({ + "is_online": True, + "in_limbo": True, + "is_initialized": False, + }) + + if command == "connected": + player_dict.update({ + "id": regex_result.group("entity_id"), + "ip": regex_result.group("player_ip"), + "steamid": player_steamid, + "owner": player_steamid + }) + + player_name = player_dict.get("name", regex_result.group("player_name")) + payload = '{} is logging into {} at {}'.format(player_name, active_dataset, last_seen_gametime_string) + + discord_payload_url = origin_module.options.get("discord_webhook", None) + if discord_payload_url is not None: + webhook = DiscordWebhook( + url=discord_payload_url, + content=payload + ) + webhook.execute() + executed_trigger = True + + if all([ + executed_trigger is True, + active_dataset is not None, + player_steamid is not None, + len(player_dict) >= 1 + ]): + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + player_steamid: player_dict + } + } + } + }) + + +trigger_meta = { + "description": "reacts to telnets player discovery messages for real time responses!", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"\[Steamworks.NET\]\s" + r"(?P.*)\s" + r"player:\s(?P.*)\s" + r"SteamId:\s(?P\d+)\s(.*)" + ), + "callback": main_function + }, { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Player\s" + r"(?P.*), " + r"entityid=(?P.*), " + r"name=(?P.*), " + r"steamid=(?P.*), " + r"steamOwner=(?P.*), " + r"ip=(?P.*)" + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/triggers/left_telnet.py b/bot/modules/players/triggers/left_telnet.py new file mode 100644 index 0000000..0878275 --- /dev/null +++ b/bot/modules/players/triggers/left_telnet.py @@ -0,0 +1,78 @@ +from .discord_webhook import DiscordWebhook +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + executed_trigger = False + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + if command == "disconnected": + player_steamid = regex_result.group("player_steamid") + + existing_player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(player_steamid, {}) + ) + player_dict = {} + player_dict.update(existing_player_dict) + player_dict.update({ + "is_online": False, + "is_initialized": False + }) + + player_name = player_dict.get("name", regex_result.group("player_name")) + last_seen_gametime_string = module.game_environment.get_last_recorded_gametime_string() + payload = '{} left {} at {}'.format(player_name, active_dataset, last_seen_gametime_string) + + discord_payload_url = origin_module.options.get("discord_webhook", None) + if discord_payload_url is not None: + webhook = DiscordWebhook( + url=discord_payload_url, + content=payload + ) + webhook.execute() + + executed_trigger = True + + if executed_trigger is True: + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + player_steamid: player_dict + } + } + } + }) + + +trigger_meta = { + "description": "reacts to telnets player disconnected message for real time responses!", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Player\s" + r"(?P.*):\s" + r"EntityID=(?P.*),\s" + r"PlayerID='(?P\d{17})',\s" + r"OwnerID='(?P\d{17})',\s" + r"PlayerName='(?P.*)'" + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/triggers/player_update_on_map.py b/bot/modules/players/triggers/player_update_on_map.py new file mode 100644 index 0000000..fcde21e --- /dev/null +++ b/bot/modules/players/triggers/player_update_on_map.py @@ -0,0 +1,108 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def send_player_update_to_map(*args, **kwargs): + """Send player updates to map view via socket.io""" + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", {}) + + if updated_values_dict is None: + return + + # Get steamid and dataset + steamid = updated_values_dict.get("steamid") + dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + if not all([dataset, steamid]): + return + + # Get full player data from DOM + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(dataset, {}) + .get(steamid, {}) + ) + + if not player_dict: + return + + # Check which clients are viewing the map + locations_module = loaded_modules_dict.get("module_locations") + if not locations_module: + return + + for clientid in module.webserver.connected_clients.keys(): + # Check if client is viewing the map in the locations widget + current_view = ( + locations_module.dom.data + .get("module_locations", {}) + .get("visibility", {}) + .get(clientid, {}) + .get("current_view", None) + ) + if current_view != "map": + continue + + # Prepare player update data + pos = player_dict.get("pos", {}) + if not pos: + continue + + player_update_data = { + "steamid": steamid, + "name": player_dict.get("name", "Player"), + "level": player_dict.get("level", 0), + "health": player_dict.get("health", 0), + "zombies": player_dict.get("zombies", 0), + "deaths": player_dict.get("deaths", 0), + "players": player_dict.get("players", 0), + "score": player_dict.get("score", 0), + "ping": player_dict.get("ping", 0), + "is_muted": player_dict.get("is_muted", False), + "is_authenticated": player_dict.get("is_authenticated", False), + "in_limbo": player_dict.get("in_limbo", False), + "is_initialized": player_dict.get("is_initialized", False), + "permission_level": player_dict.get("permission_level", None), + "dataset": dataset, + "position": { + "x": float(pos.get("x", 0)), + "y": float(pos.get("y", 0)), + "z": float(pos.get("z", 0)) + } + } + + module.webserver.send_data_to_client_hook( + module, + payload=player_update_data, + data_type="player_position_update", + clients=[clientid] + ) + + +trigger_meta = { + "description": "sends player updates to webmap clients", + "main_function": send_player_update_to_map, + "handlers": { + # Listen to all player field updates that are relevant for the map + "module_players/elements/%map_identifier%/%steamid%/pos": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/health": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/level": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/zombies": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/deaths": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/players": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/score": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/ping": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/is_muted": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/is_authenticated": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/in_limbo": send_player_update_to_map, + "module_players/elements/%map_identifier%/%steamid%/is_initialized": send_player_update_to_map, + } +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/triggers/playerspawn.py b/bot/modules/players/triggers/playerspawn.py new file mode 100644 index 0000000..496c66e --- /dev/null +++ b/bot/modules/players/triggers/playerspawn.py @@ -0,0 +1,138 @@ +from .discord_webhook import DiscordWebhook +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + command = regex_result.group("command") + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + update_player_pos = False + last_recorded_gametime_string = module.game_environment.get_last_recorded_gametime_string() + + if command == "joined the game": + player_name = regex_result.group("player_name") + payload = '{} joined {} at {}'.format(player_name, active_dataset, last_recorded_gametime_string) + + discord_payload_url = origin_module.options.get("discord_webhook", None) + if discord_payload_url is not None: + webhook = DiscordWebhook( + url=discord_payload_url, + content=payload + ) + webhook.execute() + + elif any([ + command == "EnterMultiplayer", + command == "JoinMultiplayer" + ]): + steamid = regex_result.group("player_steamid") + player_name = regex_result.group("player_name") + existing_player_dict = ( + module.dom.data.get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(steamid, {}) + ) + + player_dict = { + "name": player_name + } + player_dict.update(existing_player_dict) + + if command == "EnterMultiplayer": + player_dict["first_seen_gametime"] = last_recorded_gametime_string + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + steamid: player_dict + } + } + } + }) + elif command == "JoinMultiplayer": + default_player_password = module.default_options.get("default_player_password", None) + if player_dict.get("is_authenticated", False) is True or default_player_password is None: + message = "[66FF66]Welcome back[-] [FFFFFF]{}[-]".format(player_name) + else: + message = ( + "[66FF66]Welcome to the server[-] [FFFFFF]{player_name}[-]" + ).format( + player_name=player_name + ) + if default_player_password is not None: + message += ", [FF6666]please authenticate[-] [FFFFFF]and make yourself at home[-]" + + event_data = ['say_to_player', { + 'steamid': steamid, + 'message': message + }] + module.trigger_action_hook(origin_module.players, event_data=event_data) + elif command == "Teleport": + update_player_pos = True + + if update_player_pos: + player_to_be_updated = regex_result.group("player_steamid") + pos_after_teleport = { + "pos": { + "x": regex_result.group("pos_x"), + "y": regex_result.group("pos_y"), + "z": regex_result.group("pos_z"), + } + } + # update the players location data with the teleport ones + module.dom.data.upsert({ + "module_players": { + "elements": { + active_dataset: { + player_to_be_updated: pos_after_teleport + } + } + } + }) + + +trigger_meta = { + "description": "reacts to telnets playerspawn messages for real time responses!", + "main_function": main_function, + "triggers": [ + { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"PlayerSpawnedInWorld\s" + r"\(" + r"reason: (?P.+?),\s" + r"position: (?P.*),\s(?P.*),\s(?P.*)" + r"\):\s" + r"EntityID=(?P.*),\s" + r"PlayerID='(?P.*)',\s" + r"OwnerID='(?P.*)',\s" + r"PlayerName='(?P.*)'" + ), + "callback": main_function + }, { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + r"Player (?P.*): " + r"EntityID=(?P.*), " + r"PlayerID=\'(?P.*)\', " + r"OwnerID=\'(?P.*)\', " + r"PlayerName='(?P.*)\'$" + ), + "callback": main_function + }, { + "regex": ( + telnet_prefixes["telnet_log"]["timestamp"] + + telnet_prefixes["GMSG"]["command"] + ), + "callback": main_function + } + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/players/widgets/manage_players_widget.py b/bot/modules/players/widgets/manage_players_widget.py new file mode 100644 index 0000000..7bd813d --- /dev/null +++ b/bot/modules/players/widgets/manage_players_widget.py @@ -0,0 +1,628 @@ +from bot import loaded_modules_dict +from os import path, pardir +from collections import OrderedDict + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def get_player_table_row_css_class(player_dict): + css_classes = [] + + if player_dict.get("is_online", False): + css_classes.append("is_online") + if player_dict.get("in_limbo", False): + css_classes.append("in_limbo") + if int(player_dict.get("health", 0)) > 0: + css_classes.append("has_health") + if player_dict.get("is_initialized", False): + css_classes.append("is_initialized") + permission_level = player_dict.get("permission_level", False) + if permission_level: + css_classes.append("has_level_" + permission_level.zfill(4)) + else: + css_classes.append("has_no_level") + + return " ".join(css_classes) + + +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + current_view = module.get_current_view(dispatchers_steamid) + + if current_view == "options": + options_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "info": + show_info_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "delete-modal": + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + delete_modal_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "kick-modal": + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + kick_modal_view(module, dispatchers_steamid=dispatchers_steamid) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + + +def delete_modal_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + all_available_player_dicts = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + all_selected_elements_count = 0 + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + for map_identifier, player_dicts in all_available_player_dicts.items(): + if active_dataset == map_identifier: + for player_steamid, player_dict in player_dicts.items(): + player_is_selected_by = player_dict.get("selected_by", []) + if dispatchers_steamid in player_is_selected_by: + all_selected_elements_count += 1 + + modal_confirm_delete = module.dom_management.get_delete_confirm_modal( + module, + count=all_selected_elements_count, + target_module="module_players", + dom_element_id="player_table_modal_action_delete_button", + dom_action="delete_selected_dom_elements", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + confirmed="True" + ) + + data_to_emit = modal_confirm_delete + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="modal_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_players_widget_modal", + "type": "div", + "selector": "body > main > div" + } + ) + + +def kick_modal_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + current_view_steamid = ( + module.dom.data + .get("module_players", {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("current_view_steamid", None) + ) + + modal_confirm_kick = module.template_render_hook( + module, + template=module.templates.get_template('manage_players_widget/modal_confirm_kick.html'), + confirmed=kwargs.get("confirmed", "False"), + reason=( + "no reason provided, " + "try again in a few minutes and check if perhaps a bloodmoon is in progress ^^ " + "or something ^^" + ), + steamid=current_view_steamid + ) + + module.webserver.send_data_to_client_hook( + module, + payload=modal_confirm_kick, + data_type="modal_content", + clients=[dispatchers_steamid], + target_element={ + "id": "manage_players_widget_modal", + "type": "div", + "selector": "body > main > div" + } + ) + + +def frontend_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('manage_players_widget/view_frontend.html') + template_table_rows = module.templates.get_template('manage_players_widget/table_row.html') + template_table_header = module.templates.get_template('manage_players_widget/table_header.html') + template_table_footer = module.templates.get_template('manage_players_widget/table_footer.html') + + control_info_link = module.templates.get_template('manage_players_widget/control_info_link.html') + control_kick_link = module.templates.get_template('manage_players_widget/control_kick_link.html') + + template_options_toggle = module.templates.get_template('manage_players_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template( + 'manage_players_widget/control_switch_options_view.html' + ) + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + all_available_player_dicts = module.dom.data.get(module.get_module_identifier(), {}).get("elements", {}) + + # Build table rows efficiently using list + join + table_rows_list = [] + all_selected_elements_count = 0 + for map_identifier, player_dicts in all_available_player_dicts.items(): + if active_dataset == map_identifier: + # have the recently online players displayed first initially! + ordered_player_dicts = OrderedDict( + sorted( + player_dicts.items(), + key=lambda x: x[1].get('last_updated_servertime', ""), + reverse=True + ) + ) + + for player_steamid, player_dict in ordered_player_dicts.items(): + player_is_selected_by = player_dict.get("selected_by", []) + + player_entry_selected = False + if dispatchers_steamid in player_is_selected_by: + player_entry_selected = True + all_selected_elements_count += 1 + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + player_dict_for_template = player_dict.copy() + player_dict_for_template["dataset"] = module.dom_management.sanitize_for_html_id(player_dict.get("dataset", "")) + player_dict_for_template["dataset_original"] = player_dict.get("dataset", "") + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_players", + dom_element=player_dict_for_template, + dom_element_select_root=["selected_by"], + dom_element_entry_selected=player_entry_selected, + dom_action_active="deselect_dom_element", + dom_action_inactive="select_dom_element" + ) + + table_rows_list.append(module.template_render_hook( + module, + template=template_table_rows, + player=player_dict_for_template, + css_class=get_player_table_row_css_class(player_dict), + control_info_link=module.template_render_hook( + module, + template=control_info_link, + player=player_dict_for_template + ), + control_kick_link=module.template_render_hook( + module, + template=control_kick_link, + player=player_dict_for_template, + ), + control_select_link=control_select_link + )) + + rendered_table_rows = ''.join(table_rows_list) + + current_view = module.get_current_view(dispatchers_steamid) + + options_toggle = module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + options_view_toggle=(current_view in ["frontend", "delete-modal"]), + steamid=dispatchers_steamid + ) + ) + + dom_element_delete_button = module.dom_management.get_delete_button_dom_element( + module, + count=all_selected_elements_count, + target_module="module_players", + dom_element_id="player_table_widget_action_delete_button", + dom_action="delete_selected_dom_elements", + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + table_header=module.template_render_hook( + module, + template=template_table_header + ), + table_rows=rendered_table_rows, + table_footer=module.template_render_hook( + module, + template=template_table_footer, + action_delete_button=dom_element_delete_button + ) + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "manage_players_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def options_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('manage_players_widget/view_options.html') + template_options_toggle = module.templates.get_template('manage_players_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template('manage_players_widget/control_switch_options_view.html') + + options_toggle = module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + options_view_toggle=False, + steamid=dispatchers_steamid + ) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + widget_options=module.options + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "manage_players_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def show_info_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('manage_players_widget/view_info.html') + template_options_toggle = module.templates.get_template('manage_players_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template( + 'manage_players_widget/control_switch_options_view.html' + ) + + current_view_steamid = ( + module.dom.data + .get("module_players", {}) + .get("visibility", {}) + .get(dispatchers_steamid, {}) + .get("current_view_steamid", "frontend") + ) + + options_toggle = module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + options_view_toggle=False, + steamid=dispatchers_steamid + ) + ) + + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(current_view_steamid, None) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + player=player_dict + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "manage_players_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def table_rows(*args, ** kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + method = kwargs.get("method", None) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + if method in ["upsert", "edit", "insert"]: + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + if current_view == "frontend": + template_table_rows = module.templates.get_template('manage_players_widget/table_row.html') + control_info_link = module.templates.get_template('manage_players_widget/control_info_link.html') + control_kick_link = module.templates.get_template('manage_players_widget/control_kick_link.html') + + for player_steamid, player_dict in updated_values_dict.items(): + try: + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(player_dict["dataset"]) + table_row_id = "player_table_row_{}_{}".format( + sanitized_dataset, + str(player_steamid) + ) + # Update player_dict with sanitized dataset for template + player_dict = player_dict.copy() + player_dict["dataset"] = sanitized_dataset + player_dict["dataset_original"] = updated_values_dict[player_steamid].get("dataset", "") + except KeyError: + table_row_id = "manage_players_widget" + + player_entry_selected = False + if clientid in player_dict.get("selected_by", []): + player_entry_selected = True + + control_select_link = module.dom_management.get_selection_dom_element( + module, + target_module="module_players", + dom_element_select_root=["selected_by"], + dom_element=player_dict, + dom_element_entry_selected=player_entry_selected, + dom_action_inactive="select_dom_element", + dom_action_active="deselect_dom_element" + ) + + table_row = module.template_render_hook( + module, + template=template_table_rows, + player=player_dict, + css_class=get_player_table_row_css_class(player_dict), + control_info_link=module.template_render_hook( + module, + template=control_info_link, + player=player_dict + ), + control_kick_link=module.template_render_hook( + module, + template=control_kick_link, + player=player_dict, + ), + control_select_link=control_select_link + ) + + module.webserver.send_data_to_client_hook( + module, + payload=table_row, + data_type="table_row", + clients=[clientid], + target_element={ + "id": table_row_id, + "type": "tr", + "class": get_player_table_row_css_class(player_dict), + "selector": "body > main > div > div#manage_players_widget > main > table > tbody" + } + ) + elif method == "remove": + player_origin = updated_values_dict[2] + player_steamid = updated_values_dict[3] + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_origin = module.dom_management.sanitize_for_html_id(player_origin) + module.webserver.send_data_to_client_hook( + module, + data_type="remove_table_row", + clients="all", + target_element={ + "id": "player_table_row_{}_{}".format( + sanitized_origin, + str(player_steamid) + ) + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_widget(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + active_dataset = module.dom.data.get("module_game_environment", {}).get("active_dataset", None) + + method = kwargs.get("method", None) + if method in ["update"]: + original_player_dict = ( + module.dom.data + .get("module_players", {}) + .get("elements", {}) + .get(active_dataset, {}) + .get(updated_values_dict.get("steamid", None), {}) + ) + player_clients_to_update = list(module.webserver.connected_clients.keys()) + + for clientid in player_clients_to_update: + try: + module_players = module.dom.data.get("module_players", {}) + current_view = module.get_current_view(clientid) + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(original_player_dict.get("dataset", "")) + table_row_id = "player_table_row_{}_{}".format( + sanitized_dataset, + str(original_player_dict.get("steamid", None)) + ) + # Update dicts with sanitized dataset + original_dataset = original_player_dict.get("dataset", "") + updated_values_dict_sanitized = updated_values_dict.copy() + updated_values_dict_sanitized["dataset"] = sanitized_dataset + updated_values_dict_sanitized["dataset_original"] = original_dataset + original_player_dict_sanitized = original_player_dict.copy() + original_player_dict_sanitized["dataset"] = sanitized_dataset + original_player_dict_sanitized["dataset_original"] = original_dataset + + if current_view == "frontend": + module.webserver.send_data_to_client_hook( + module, + payload=updated_values_dict_sanitized, + data_type="table_row_content", + clients=[clientid], + method="update", + target_element={ + "id": table_row_id, + "parent_id": "manage_players_widget", + "module": "players", + "type": "tr", + "selector": "body > main > div > div#manage_players_widget", + "class": get_player_table_row_css_class(original_player_dict_sanitized), + } + ) + elif current_view == "info": + module.webserver.send_data_to_client_hook( + module, + payload=updated_values_dict_sanitized, + data_type="table_row_content", + clients=[clientid], + method="update", + target_element={ + "id": table_row_id, + "parent_id": "manage_players_widget", + "module": "players", + "type": "tr", + "selector": "body > main > div > div#manage_players_widget", + "class": get_player_table_row_css_class(updated_values_dict_sanitized), + } + ) + except AttributeError as error: + # probably dealing with a player_dict here, not the players dict + pass + except KeyError as error: + pass + + +def update_selection_status(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + sanitized_dataset = module.dom_management.sanitize_for_html_id(updated_values_dict["dataset"]) + + module.dom_management.update_selection_status( + *args, **kwargs, + target_module=module, + dom_action_active="deselect_dom_element", + dom_action_inactive="select_dom_element", + dom_element_id={ + "id": "player_table_row_{}_{}_control_select_link".format( + sanitized_dataset, + updated_values_dict["owner"] + ) + } + ) + + update_delete_button_status(module, *args, **kwargs) + + +def update_delete_button_status(*args, **kwargs): + module = args[0] + + module.dom_management.update_delete_button_status( + *args, **kwargs, + dom_element_root=module.dom_element_root, + dom_element_select_root=module.dom_element_select_root, + target_module=module, + dom_action="delete_selected_dom_elements", + dom_element_id={ + "id": "player_table_widget_action_delete_button" + } + ) + + +def update_actions_status(*args, **kwargs): + """ we want to update the action status here + not all actions can work all the time, some depend on a player being online for example""" + module = args[0] + control_info_link = module.templates.get_template('manage_players_widget/control_info_link.html') + control_kick_link = module.templates.get_template('manage_players_widget/control_kick_link.html') + + player_dict = kwargs.get("updated_values_dict", None) + + # Sanitize dataset for HTML ID (replace spaces with underscores, lowercase) + original_dataset = player_dict.get("dataset", "") + player_dict_sanitized = player_dict.copy() + player_dict_sanitized["dataset"] = module.dom_management.sanitize_for_html_id(original_dataset) + player_dict_sanitized["dataset_original"] = original_dataset + + rendered_control_info_link = module.template_render_hook( + module, + template=control_info_link, + player=player_dict_sanitized + ) + rendered_control_kick_link = module.template_render_hook( + module, + template=control_kick_link, + player=player_dict_sanitized + ) + payload = rendered_control_info_link + rendered_control_kick_link + module.webserver.send_data_to_client_hook( + module, + payload=payload, + data_type="element_content", + clients="all", + method="update", + target_element={ + "id": "player_table_row_{}_{}_actions".format( + player_dict_sanitized.get("dataset"), + player_dict_sanitized.get("steamid") + ) + } + ) + + +widget_meta = { + "description": "sends and updates a table of all currently known players", + "main_widget": select_view, + "handlers": { + # the %abc% placeholders can contain any text at all, it has no effect on anything but code-readability + # the third line could just as well read + # "module_players/elements/%x%/%x%/%x%/selected_by": update_selection_status + # and would still function the same as + # "module_players/elements/%map_identifier%/%steamid%/%element_identifier%/selected_by": + # update_selection_status + "module_players/visibility/%steamid%/current_view": + select_view, + "module_players/elements/%map_identifier%/%steamid%": + table_rows, + "module_players/elements/%map_identifier%/%steamid%/pos": + update_widget, + "module_players/elements/%map_identifier%/%steamid%/selected_by": + update_selection_status, + "module_players/elements/%map_identifier%/%steamid%/is_initialized": + update_actions_status + # "module_players/elements/%map_identifier%/%steamid%/%element_identifier%/is_enabled": + # update_enabled_flag, + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/storage/__init__.py b/bot/modules/storage/__init__.py new file mode 100644 index 0000000..eb87a00 --- /dev/null +++ b/bot/modules/storage/__init__.py @@ -0,0 +1,60 @@ +from os import path +from time import time +from bot.module import Module +from bot import loaded_modules_dict +from .persistent_dict import PersistentDict + + +class Storage(Module): + root_dir = str + + def __init__(self): + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:] + }) + + setattr(self, "required_modules", [ + "module_dom" + ]) + + self.next_cycle = 0 + self.run_observer_interval = 15 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_storage" + + def load_persistent_dict_to_dom(self): + with PersistentDict(path.join(self.root_dir, "storage.json"), 'c', format="json") as storage: + self.dom.data.update(storage) +# with PersistentDict(path.join(self.root_dir, "storage_pickle"), 'c', format="pickle") as storage: +# self.dom.data.update(storage) + + def save_dom_to_persistent_dict(self): + with PersistentDict(path.join(self.root_dir, "storage.json"), 'c', format="json") as storage: + storage.update(self.dom.data) +# with PersistentDict(path.join(self.root_dir, "storage_pickle"), 'c', format="pickle") as storage: +# storage.update(self.dom.data) + + # region Standard module stuff + def setup(self, options=dict): + self.root_dir = path.dirname(path.abspath(__file__)) + Module.setup(self, options) + + def start(self): + Module.start(self) + self.load_persistent_dict_to_dom() + # endregion + + def run(self): + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + self.save_dom_to_persistent_dict() + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + + +loaded_modules_dict[Storage().get_module_identifier()] = Storage() diff --git a/bot/modules/storage/persistent_dict.py b/bot/modules/storage/persistent_dict.py new file mode 100644 index 0000000..39502ac --- /dev/null +++ b/bot/modules/storage/persistent_dict.py @@ -0,0 +1,109 @@ +import pickle +import json +import csv +import os +import shutil +import base64 + + +""" found on https://stackoverflow.com/a/36252257/8967590 """ +class PythonObjectEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (list, dict, str, int, float, bool, type(None))): + return super().default(obj) + return {'_python_object': base64.b64encode(pickle.dumps(obj)).decode('utf-8')} + + +def as_python_object(dct): + if '_python_object' in dct: + return pickle.loads(base64.b64decode(dct['_python_object'].encode('utf-8'))) + return dct + + +class PersistentDict(dict): + """ Persistent dictionary with an API compatible with shelve and anydbm. + + The dict is kept in memory, so the dictionary operations run as fast as + a regular dictionary. + + Write to disk is delayed until close or sync (similar to gdbm's fast mode). + + Input file format is automatically discovered. + Output file format is selectable between pickle, json, and csv. + All three serialization formats are backed by fast C implementations. + + """ + + def __init__(self, filename, flag='c', mode=None, format='pickle', *args, **kwargs): + self.flag = flag # r=readonly, c=create, or n=new + self.mode = mode # None or an octal triple like 0644 + self.format = format # 'csv', 'json', or 'pickle' + self.filename = filename + if flag != 'n' and os.access(filename, os.R_OK): + fileobj = open(filename, 'rb' if format == 'pickle' else 'r') + with fileobj: + self.load(fileobj) + dict.__init__(self, *args, **kwargs) + + def sync(self): + """Write dict to disk""" + if self.flag == 'r': + return + filename = self.filename + tempname = filename + '.tmp' + fileobj = open(tempname, 'wb' if self.format == 'pickle' else 'w') + try: + self.dump(fileobj) + except (IOError, OSError, pickle.PickleError, json.JSONEncoder) as e: + # Clean up temp file if serialization fails + os.remove(tempname) + raise + finally: + fileobj.close() + shutil.move(tempname, self.filename) # atomic commit + if self.mode is not None: + os.chmod(self.filename, self.mode) + + def close(self): + self.sync() + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self.close() + + def dump(self, fileobj): + if self.format == 'csv': + csv.writer(fileobj).writerows(self.items()) + elif self.format == 'json': + json.dump(self, fileobj, separators=(',', ':'), sort_keys=True, indent=4, cls=PythonObjectEncoder) + elif self.format == 'pickle': + pickle.dump(dict(self), fileobj, 2) + else: + raise NotImplementedError('Unknown format: ' + repr(self.format)) + + def load(self, fileobj): + """ + Try to load file using different formats. + + Attempts pickle, json, then csv in that order. This allows + automatic format detection when reading existing files. + """ + # try formats from most restrictive to least restrictive + for loader in ( + (pickle.load, {}), + (json.load, { + "object_hook": as_python_object} + ), + (csv.reader, {}) + ): + fileobj.seek(0) + try: + return self.update(loader[0](fileobj, **loader[1])) + except (KeyboardInterrupt, SystemExit): + raise # Don't suppress these critical exceptions + except Exception: + # Try next loader - expected to fail for wrong formats + pass + raise ValueError('File not in a supported format') diff --git a/bot/modules/telnet/__init__.py b/bot/modules/telnet/__init__.py new file mode 100644 index 0000000..3208272 --- /dev/null +++ b/bot/modules/telnet/__init__.py @@ -0,0 +1,466 @@ +import re +from bot.module import Module +from bot import loaded_modules_dict +from bot.logger import get_logger +from bot.constants import TELNET_TIMEOUT_NORMAL, TELNET_TIMEOUT_RECONNECT +from time import time +from collections import deque +import telnetlib + +logger = get_logger("telnet") + + +class Telnet(Module): + tn = object + + telnet_buffer = str + valid_telnet_lines = deque + + telnet_lines_to_process = deque + telnet_command_queue = deque + + def __init__(self): + self.telnet_command_queue = deque() + setattr(self, "default_options", { + "module_name": self.get_module_identifier()[7:], + "host": "127.0.0.1", + "port": 8081, + "password": "thisissecret", + "web_username": "", + "web_password": "", + "max_queue_length": 100, + "run_observer_interval": 3, + "run_observer_interval_idle": 10, + "max_telnet_buffer": 16384, + "max_command_queue_execution": 6, + "match_types_generic": { + 'log_start': [ + r"\A(?P\d{4}.+?)\s(?P.+?)\sINF .*", + r"\ATime:\s(?P.*)m\s", + ], + 'log_end': [ + r"\r\n$", + r"\sby\sTelnet\sfrom\s(.*)\:(\d.*)\s*$" + ] + } + }) + setattr(self, "required_modules", [ + "module_dom", + "module_webserver" + ]) + + self.next_cycle = 0 + self.telnet_response = "" + + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_telnet" + + def on_socket_connect(self, steamid): + Module.on_socket_connect(self, steamid) + + def on_socket_disconnect(self, steamid): + Module.on_socket_disconnect(self, steamid) + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + + self.telnet_lines_to_process = deque(maxlen=self.options["max_queue_length"]) + self.valid_telnet_lines = deque(maxlen=self.options["max_queue_length"]) + self.run_observer_interval = self.options.get( + "run_observer_interval", self.default_options.get("run_observer_interval", None) + ) + self.run_observer_interval_idle = self.options.get( + "run_observer_interval_idle", self.default_options.get("run_observer_interval_idle", None) + ) + self.max_command_queue_execution = self.options.get( + "max_command_queue_execution", self.default_options.get("max_command_queue_execution", None) + ) + self.telnet_buffer = "" + + self.last_execution_time = 0.0 + + setattr(self, "last_connection_loss", None) + setattr(self, "recent_telnet_response", None) + # endregion + + # region Handling telnet initialization and authentication + def setup_telnet(self): + try: + connection = telnetlib.Telnet( + self.options.get("host"), + self.options.get("port"), + timeout=TELNET_TIMEOUT_NORMAL + ) + self.tn = self.authenticate(connection, self.options.get("password")) + except Exception as error: + logger.error("telnet_connection_failed", + host=self.options.get("host"), + port=self.options.get("port"), + error=str(error), + error_type=type(error).__name__) + raise IOError + + return True + + def authenticate(self, connection, password): + try: + # Waiting for the prompt. + found_prompt = False + while found_prompt is not True: + telnet_response = connection.read_until(b"\r\n", timeout=TELNET_TIMEOUT_NORMAL).decode("utf-8") + if re.match(r"Please enter password:\r\n", telnet_response): + found_prompt = True + else: + raise IOError + + # Sending password. + full_auth_response = '' + authenticated = False + connection.write(password.encode('ascii') + b"\r\n") + while authenticated is not True: # loop until authenticated, it's required + telnet_response = connection.read_until(b"\r\n").decode("utf-8") + full_auth_response += telnet_response.rstrip() + # last 'welcome' line from the games telnet. it might change with a new game-version + if re.match(r"Password incorrect, please enter password:\r\n", telnet_response) is not None: + logger.error("telnet_auth_failed", + host=self.options.get("host"), + port=self.options.get("port"), + reason="incorrect password") + raise ValueError + if re.match(r"Logon successful.\r\n", telnet_response) is not None: + authenticated = True + + # Waiting for banner. + full_banner = '' + displayed_welcome = False + while displayed_welcome is not True: # loop until ready, it's required + telnet_response = connection.read_until(b"\r\n").decode("utf-8") + full_banner += telnet_response.rstrip("\r\n") + if re.match( + r"Press 'help' to get a list of all commands. Press 'exit' to end session.", + telnet_response + ): + displayed_welcome = True + + except Exception as e: + raise IOError + + # Connection successful - no log needed + return connection + # endregion + + # region handling and preparing telnet-lines + def is_a_valid_line(self, telnet_line): + telnet_response_is_a_valid_line = False + if self.has_valid_start(telnet_line) and self.has_valid_end(telnet_line): + telnet_response_is_a_valid_line = True + + return telnet_response_is_a_valid_line + + def has_valid_start(self, telnet_response): + telnet_response_has_valid_start = False + for match_type in self.options.get("match_types_generic").get("log_start"): + if re.match(match_type, telnet_response): + telnet_response_has_valid_start = True + + return telnet_response_has_valid_start + + def has_valid_end(self, telnet_response): + telnet_response_has_valid_end = False + for match_type in self.options.get("match_types_generic").get("log_end"): + if re.search(match_type, telnet_response): + telnet_response_has_valid_end = True + + return telnet_response_has_valid_end + + def has_mutliple_lines(self, telnet_response): + telnet_response_has_multiple_lines = False + telnet_response_count = telnet_response.count(b"\r\n") + if telnet_response_count >= 1: + telnet_response_has_multiple_lines = telnet_response_count + + return telnet_response_has_multiple_lines + + @staticmethod + def extract_lines(telnet_response): + return [telnet_line for telnet_line in telnet_response.splitlines(True)] + + def get_a_bunch_of_lines_from_queue(self, this_many_lines): + telnet_lines = [] + current_queue_length = 0 + done = False + while (current_queue_length < this_many_lines) and not done: + try: + telnet_lines.append(self.telnet_lines_to_process.popleft()) + current_queue_length += 1 + except IndexError: + done = True + + if len(telnet_lines) >= 1: + return telnet_lines + else: + return [] + + def add_telnet_command_to_queue(self, command): + if command not in self.telnet_command_queue: + self.telnet_command_queue.appendleft(command) + return True + + return False + + def execute_telnet_command_queue(self, this_many_lines): + telnet_command_list = [] + current_queue_length = 0 + done = False + initial_queue_length = len(self.telnet_command_queue) + while (current_queue_length < this_many_lines) and not done: + try: + telnet_command_list.append(self.telnet_command_queue.popleft()) + current_queue_length += 1 + except IndexError: + done = True + + remaining_queue_length = len(self.telnet_command_queue) + # print(initial_queue_length, ":", remaining_queue_length) + + for telnet_command in reversed(telnet_command_list): + command = "{command}{line_end}".format(command=telnet_command, line_end="\r\n") + + try: + self.tn.write(command.encode('ascii')) + + except Exception as error: + logger.error("telnet_command_send_failed", + command=telnet_command, + error=str(error), + error_type=type(error).__name__, + queue_size=remaining_queue_length) + # endregion + + # ==================== Line Processing Helper Methods ==================== + + def _should_exclude_from_logs(self, telnet_line: str) -> bool: + """Check if a telnet line should be excluded from logs.""" + elements_excluded_from_logs = [ + "'lp'", "'gettime'", "'listents'", # system calls + "INF Time: ", "SleeperVolume", " killed by " # irrelevant lines for now + ] + return any(exclude in telnet_line for exclude in elements_excluded_from_logs) + + def _store_valid_line(self, valid_line: str) -> None: + """Store a valid telnet line in DOM.""" + # Store in DOM if clients are connected and line is relevant + if not self._should_exclude_from_logs(valid_line): + if len(self.webserver.connected_clients) >= 1: + self.dom.data.append({ + self.get_module_identifier(): { + "telnet_lines": valid_line + } + }, maxlen=150) + + # Debug log only (disabled by default to avoid spam) + # Uncomment next line and enable debug logging if needed for troubleshooting + # logger.debug("telnet_line_received", line=valid_line[:100]) + + self.valid_telnet_lines.append(valid_line) + + def _process_first_component(self, component: str) -> str: + """ + Process the first component of telnet response. + + This might be the remainder of the previous run combined with new data. + Returns the validated line or None. + """ + if self.recent_telnet_response is not None: + # Try to combine with previous incomplete response + combined_line = f"{self.recent_telnet_response}{component}" + if self.is_a_valid_line(combined_line): + self.recent_telnet_response = None + return combined_line.rstrip("\r\n") + else: + # Combined line still doesn't make sense + stripped = combined_line.rstrip("\r\n") + logger.warn("telnet_invalid_line_combined", line=stripped) + self.recent_telnet_response = None + return None + else: + # No previous response - check if this is an incomplete line to store + if self.has_valid_start(component): + self.recent_telnet_response = component + else: + # Invalid start - warn if not empty + stripped = component.rstrip("\r\n") + if len(stripped) != 0: + logger.warn("telnet_invalid_line_start", line=stripped) + return None + + def _process_last_component(self, component: str) -> str: + """ + Process the last component of telnet response. + + This might be the start of the next run. + Returns the validated line or None. + """ + if self.has_valid_start(component): + # Store for next run + self.recent_telnet_response = component + # else: part of a telnet-command response, ignore + return None + + def _process_middle_component(self, component: str) -> str: + """ + Process a middle component (neither first nor last). + + These are usually incomplete lines or command responses. + Returns None as these are typically not valid complete lines. + """ + # Middle components are usually fragmented, ignore them + return None + + def _process_line_component( + self, + component: str, + component_index: int, + total_components: int + ) -> str: + """ + Process a single component of the telnet response. + + Args: + component: The line component to process + component_index: 1-based index of this component + total_components: Total number of components + + Returns: + Validated telnet line or None + """ + # Check if it's a complete, valid line first + if self.is_a_valid_line(component): + return component.rstrip("\r\n") + + # Handle incomplete lines based on position + is_first = (component_index == 1) + is_last = (component_index == total_components) + is_single = (total_components == 1) + + if is_first and is_single: + # Single incomplete component - special handling + return self._process_first_component(component) + elif is_first: + # First of multiple - might combine with previous + return self._process_first_component(component) + elif is_last: + # Last component - might be start of next + return self._process_last_component(component) + else: + # Middle component - usually fragmented + return self._process_middle_component(component) + + def _process_telnet_response_lines(self) -> None: + """ + Process telnet response and extract valid lines. + + Handles line fragmentation across multiple reads and stores + valid lines for further processing. + """ + telnet_response_components = self.extract_lines(self.telnet_response) + total_components = len(telnet_response_components) + + for index, component in enumerate(telnet_response_components, start=1): + valid_line = self._process_line_component( + component, + index, + total_components + ) + + if valid_line is not None: + self.telnet_lines_to_process.append(valid_line) + self._store_valid_line(valid_line) + + def _handle_connection_error(self, error: Exception) -> None: + """Handle telnet connection errors and attempt reconnection.""" + try: + self.setup_telnet() + self.dom.data.upsert({ + self.get_module_identifier(): { + "server_is_online": True + } + }) + except (OSError, Exception, ConnectionRefusedError) as error: + self.dom.data.upsert({ + self.get_module_identifier(): { + "server_is_online": False + } + }) + self.telnet_buffer = "" + self.telnet_response = "" + + # Only log on first connection loss, not on every retry + if self.last_connection_loss is None: + logger.error("telnet_server_unreachable", + host=self.options.get("host"), + port=self.options.get("port"), + error=str(error), + error_type=type(error).__name__, + note="will retry every 10 seconds") + + self.last_connection_loss = time() + + def _update_telnet_buffer(self) -> None: + """Update the telnet buffer with new response data.""" + self.telnet_buffer += self.telnet_response.lstrip() + max_telnet_buffer = self.options.get( + "max_telnet_buffer", + self.default_options.get("max_telnet_buffer", 12288) + ) + # Trim buffer to max size + self.telnet_buffer = self.telnet_buffer[-max_telnet_buffer:] + + # Expose buffer to other modules via DOM + self.dom.data.upsert({ + self.get_module_identifier(): { + "telnet_buffer": self.telnet_buffer + } + }) + + # ==================== Main Run Loop ==================== + + def run(self): + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + # Throttle connection attempts: only try if connected or timeout passed since last failure + can_attempt_connection = ( + self.last_connection_loss is None or + profile_start > self.last_connection_loss + TELNET_TIMEOUT_RECONNECT + ) + + if can_attempt_connection: + try: + self.telnet_response = self.tn.read_very_eager().decode("utf-8") + except (AttributeError, EOFError, ConnectionAbortedError, ConnectionResetError) as error: + self._handle_connection_error(error) + except Exception as error: + logger.error("telnet_unforeseen_error", + error=str(error), + error_type=type(error).__name__, + host=self.options.get("host"), + port=self.options.get("port")) + + # Process any telnet response data + if len(self.telnet_response) > 0: + self._update_telnet_buffer() + self._process_telnet_response_lines() + + if self.dom.data.get(self.get_module_identifier()).get("server_is_online") is True: + self.execute_telnet_command_queue(self.max_command_queue_execution) + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + + +loaded_modules_dict[Telnet().get_module_identifier()] = Telnet() diff --git a/bot/modules/telnet/actions/toggle_telnet_widget_view.py b/bot/modules/telnet/actions/toggle_telnet_widget_view.py new file mode 100644 index 0000000..b219770 --- /dev/null +++ b/bot/modules/telnet/actions/toggle_telnet_widget_view.py @@ -0,0 +1,45 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + + if action == "show_options": + current_view = "options" + elif action == "show_frontend": + current_view = "frontend" + elif action == "show_test": + current_view = "test" + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + module.set_current_view(dispatchers_steamid, { + "current_view": current_view + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": "Shows information about Telnet", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/telnet/templates/jinja2_macros.html b/bot/modules/telnet/templates/jinja2_macros.html new file mode 100644 index 0000000..b5aa5ed --- /dev/null +++ b/bot/modules/telnet/templates/jinja2_macros.html @@ -0,0 +1,56 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ active_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- else -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ inactive_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- endif -%} +{%- endmacro -%} + +{%- macro construct_view_menu(views, current_view, module_name, steamid, default_view='frontend') -%} +{# +Dynamically construct a navigation menu for widget views. + +Parameters: +views: Dict of view configurations +Example: { +'frontend': {'label_active': 'back', 'label_inactive': 'main', 'action': 'show_frontend'}, +'options': {'label_active': 'back', 'label_inactive': 'options', 'action': 'show_options'} +} +current_view: Current active view name (string) +module_name: Module name for socket.io event (e.g., 'telnet') +steamid: User's steamid for action parameters +default_view: View to return to when deactivating (default: 'frontend') +#} +{%- for view_id, config in views.items() -%} +{%- if config.get('include_in_menu', True) -%} +{%- set is_active = (current_view == view_id) -%} +{%- set label_active = config.get('label_active', config.get('label', 'back')) -%} +{%- set label_inactive = config.get('label_inactive', config.get('label', view_id)) -%} +{%- set action = config.get('action', 'show_' ~ view_id) -%} +{%- set default_action = config.get('default_action', 'show_' ~ default_view) -%} + +
+ {{ construct_toggle_link( + is_active, + label_active, + ['widget_event', [module_name, ['toggle_' ~ module_name ~ '_widget_view', {'steamid': steamid, 'action': default_action}]]], + label_inactive, + ['widget_event', [module_name, ['toggle_' ~ module_name ~ '_widget_view', {'steamid': steamid, 'action': action}]]] + )}} +
+{%- endif -%} +{%- endfor -%} +{%- endmacro -%} \ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/control_switch_options_view.html b/bot/modules/telnet/templates/telnet_log_widget/control_switch_options_view.html new file mode 100644 index 0000000..552e516 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/control_switch_options_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + options_view_toggle, + "options", ['widget_event', ['telnet', ['toggle_telnet_widget_view', {'steamid': steamid, "action": "show_options"}]]], + "back", ['widget_event', ['telnet', ['toggle_telnet_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/telnet/templates/telnet_log_widget/control_switch_view.html b/bot/modules/telnet/templates/telnet_log_widget/control_switch_view.html new file mode 100644 index 0000000..c1c2360 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/control_switch_view.html @@ -0,0 +1,3 @@ +
+ {{ control_switch_options_view }} +
\ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/control_view_menu.html b/bot/modules/telnet/templates/telnet_log_widget/control_view_menu.html new file mode 100644 index 0000000..98a9c84 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/control_view_menu.html @@ -0,0 +1,10 @@ +{%- from 'jinja2_macros.html' import construct_view_menu with context -%} +
+ {{ construct_view_menu( + views=views, + current_view=current_view, + module_name='telnet', + steamid=steamid, + default_view='frontend' + )}} +
diff --git a/bot/modules/telnet/templates/telnet_log_widget/log_line.html b/bot/modules/telnet/templates/telnet_log_widget/log_line.html new file mode 100644 index 0000000..ffe7059 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/log_line.html @@ -0,0 +1 @@ +{{ log_line }} diff --git a/bot/modules/telnet/templates/telnet_log_widget/table_footer.html b/bot/modules/telnet/templates/telnet_log_widget/table_footer.html new file mode 100644 index 0000000..a7cc084 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/table_footer.html @@ -0,0 +1,3 @@ + + Log + \ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/table_header.html b/bot/modules/telnet/templates/telnet_log_widget/table_header.html new file mode 100644 index 0000000..a7cc084 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/table_header.html @@ -0,0 +1,3 @@ + + Log + \ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/view_frontend.html b/bot/modules/telnet/templates/telnet_log_widget/view_frontend.html new file mode 100644 index 0000000..393c9a0 --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/view_frontend.html @@ -0,0 +1,27 @@ +
+
+ Telnet Log +
+
+ +
+ + + + {{ table_header }} + + + {{ log_lines }} + + + {{ table_footer }} + +
+ standard message + player chat + login / logout + bot command +
+
\ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/view_options.html b/bot/modules/telnet/templates/telnet_log_widget/view_options.html new file mode 100644 index 0000000..4ab497f --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/view_options.html @@ -0,0 +1,27 @@ +
+
+ Telnet Log +
+
+ +
+ + + + + + + + + + + {% for key, value in widget_options.items() %} + + + + {% endfor %} + +
Telnet Log Options
widget-options
{{key}}{{value}}
+
\ No newline at end of file diff --git a/bot/modules/telnet/templates/telnet_log_widget/view_test.html b/bot/modules/telnet/templates/telnet_log_widget/view_test.html new file mode 100644 index 0000000..aebe10d --- /dev/null +++ b/bot/modules/telnet/templates/telnet_log_widget/view_test.html @@ -0,0 +1,22 @@ +
+
+ Telnet Log +
+
+ +
+ + + + + + + + + + + +
Test View
Test
+
diff --git a/bot/modules/telnet/triggers/server_time.py b/bot/modules/telnet/triggers/server_time.py new file mode 100644 index 0000000..7a5a5ab --- /dev/null +++ b/bot/modules/telnet/triggers/server_time.py @@ -0,0 +1,36 @@ +from bot import loaded_modules_dict +from bot import telnet_prefixes +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +trigger_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(origin_module, module, regex_result): + datetime = regex_result.group("datetime") + last_recorded_datetime = module.dom.data.get("module_telnet", {}).get("last_recorded_servertime", "") + executed_trigger = False + if datetime is not None: + executed_trigger = True + + if all([ + executed_trigger is True, + datetime > last_recorded_datetime + ]): + module.dom.data.upsert({ + "module_telnet": { + "last_recorded_servertime": datetime + } + }) + + +trigger_meta = { + "description": "DISABLED: Modern 7D2D (V 2.x+) no longer includes timestamps in telnet output", + "main_function": main_function, + "triggers": [ + # Disabled: Modern 7D2D servers no longer include datetime/stardate in telnet responses + # This trigger is obsolete for modern server versions + ] +} + +loaded_modules_dict["module_" + module_name].register_trigger(trigger_name, trigger_meta) diff --git a/bot/modules/telnet/widgets/telnet_log_widget.py b/bot/modules/telnet/widgets/telnet_log_widget.py new file mode 100644 index 0000000..8f4a825 --- /dev/null +++ b/bot/modules/telnet/widgets/telnet_log_widget.py @@ -0,0 +1,233 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +# View Registry (mirrors locations menu pattern, but only one visible button here) +VIEW_REGISTRY = { + 'frontend': { + 'label_active': 'back', + 'label_inactive': 'main', + 'action': 'show_frontend', + 'include_in_menu': False + }, + 'options': { + 'label_active': 'back', + 'label_inactive': 'options', + 'action': 'show_options', + 'include_in_menu': True + }, + 'test': { + 'label_active': 'back', + 'label_inactive': 'test', + 'action': 'show_test', + 'include_in_menu': True + } +} + + +def get_log_line_css_class(log_line): + css_classes = [ + "log_line" + ] + + if r"INF Chat" in log_line: + css_classes.append("game_chat") + if r"(BCM) Command from" in log_line: + css_classes.append("bot_command") + if any([ + r"joined the game" in log_line, + r"left the game" in log_line + ]): + css_classes.append("player_logged") + + return " ".join(css_classes) + + +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + current_view = module.get_current_view(dispatchers_steamid) + if current_view == "options": + options_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == "test": + test_view(module, dispatchers_steamid=dispatchers_steamid) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + + +def frontend_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + telnet_log_frontend = module.templates.get_template('telnet_log_widget/view_frontend.html') + template_table_header = module.templates.get_template('telnet_log_widget/table_header.html') + log_line = module.templates.get_template('telnet_log_widget/log_line.html') + + # new view menu (pattern from locations module) + template_view_menu = module.templates.get_template('telnet_log_widget/control_view_menu.html') + + if len(module.webserver.connected_clients) >= 1: + telnet_lines = module.dom.data.get("module_telnet", {}).get("telnet_lines", {}) + if len(telnet_lines) >= 1: + # Build log lines efficiently using list comprehension + log_lines_list = [] + for line in reversed(telnet_lines): + css_class = get_log_line_css_class(line) + log_lines_list.append(module.template_render_hook( + module, + template=log_line, + log_line=line, + css_class=css_class + )) + log_lines = ''.join(log_lines_list) + + current_view = module.get_current_view(dispatchers_steamid) + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid + ) + data_to_emit = module.template_render_hook( + module, + template=telnet_log_frontend, + options_toggle=options_toggle, + log_lines=log_lines, + table_header=module.template_render_hook( + module, + template=template_table_header + ) + ) + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "telnet_log_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def options_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_frontend = module.templates.get_template('telnet_log_widget/view_options.html') + template_view_menu = module.templates.get_template('telnet_log_widget/control_view_menu.html') + + current_view = module.get_current_view(dispatchers_steamid) + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=options_toggle, + widget_options=module.options + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "telnet_log_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def test_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + template_test = module.templates.get_template('telnet_log_widget/view_test.html') + template_view_menu = module.templates.get_template('telnet_log_widget/control_view_menu.html') + + current_view = module.get_current_view(dispatchers_steamid) + options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid + ) + + data_to_emit = module.template_render_hook( + module, + template=template_test, + options_toggle=options_toggle + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + method="update", + target_element={ + "id": "telnet_log_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def update_widget(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + # Iterate directly over connected clients (no need to convert to list) + for clientid in module.webserver.connected_clients.keys(): + current_view = module.get_current_view(clientid) + if current_view == "frontend": + telnet_log_line = module.templates.get_template('telnet_log_widget/log_line.html') + css_class = get_log_line_css_class(updated_values_dict["telnet_lines"]) + data_to_emit = module.template_render_hook( + module, + template=telnet_log_line, + log_line=updated_values_dict["telnet_lines"], + css_class=css_class + ) + + module.webserver.send_data_to_client_hook( + module, + method="prepend", + data_type="widget_content", + payload=data_to_emit, + clients=[clientid], + target_element={ + "id": "telnet_log_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +widget_meta = { + "description": "displays a bunch of telnet lines, updating in real time", + "main_widget": select_view, + "handlers": { + "module_telnet/visibility/%steamid%/current_view": select_view, + "module_telnet/telnet_lines": update_widget, + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/modules/webserver/__init__.py b/bot/modules/webserver/__init__.py new file mode 100644 index 0000000..7dce24f --- /dev/null +++ b/bot/modules/webserver/__init__.py @@ -0,0 +1,600 @@ +import functools +import os +from bot import started_modules_dict +from bot.constants import WEBSOCKET_PING_TIMEOUT, WEBSOCKET_PING_INTERVAL +from flask_socketio import disconnect + +from bot.module import Module +from bot import loaded_modules_dict +from bot.logger import get_logger +from .user import User + +import re +from time import time +from socket import socket, AF_INET, SOCK_DGRAM +from flask import Flask, request, redirect, session, Response +from markupsafe import Markup +from flask_login import LoginManager, login_required, login_user, current_user, logout_user +from flask_socketio import SocketIO, emit +from requests import post, get +from urllib.parse import urlencode +from collections.abc import KeysView +from threading import Thread +import string +import random + +# Initialize logger for webserver module +logger = get_logger("webserver") + + +class Webserver(Module): + app = object + websocket = object + login_manager = object + + connected_clients = dict + broadcast_queue = dict + send_data_to_client_hook = object + game_server_session_id = None + + def __init__(self): + setattr(self, "default_options", { + "title": "chrani-bot tng", + "module_name": self.get_module_identifier()[7:], + "host": "0.0.0.0", + "port": 5000, + "Flask_secret_key": "thisissecret", + "SocketIO_asynch_mode": "gevent", + "SocketIO_use_reloader": False, + "SocketIO_debug": False, + "engineio_logger": False + }) + setattr(self, "required_modules", [ + 'module_dom' + ]) + self.next_cycle = 0 + self.send_data_to_client_hook = self.send_data_to_client + self.run_observer_interval = 5 + Module.__init__(self) + + @staticmethod + def get_module_identifier(): + return "module_webserver" + + # region SocketIO stuff + @staticmethod + def dispatch_socket_event(target_module, event_data, dispatchers_steamid): + module_identifier = "module_{}".format(target_module) + try: + started_modules_dict[module_identifier].on_socket_event(event_data, dispatchers_steamid) + except KeyError as error: + logger.error("socket_event_module_not_found", + user=dispatchers_steamid, + target_module=module_identifier, + error=str(error)) + + @staticmethod + def authenticated_only(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + if not current_user.is_authenticated: + disconnect() + else: + return f(*args, **kwargs) + + return wrapped + + # Reusable SystemRandom instance for efficient token generation + _random = random.SystemRandom() + + @classmethod + def random_string(cls, length): + """Generate a random string of given length using uppercase letters and digits.""" + chars = string.ascii_uppercase + string.digits + return ''.join(cls._random.choice(chars) for _ in range(length)) + # endregion + + # region Standard module stuff + def setup(self, options=dict): + Module.setup(self, options) + + if self.options.get("host") == self.default_options.get("host"): + self.options["host"] = self.get_ip() + + self.connected_clients = {} + + app = Flask(__name__) + app.config["SECRET_KEY"] = self.options.get("Flask_secret_key", self.default_options.get("Flask_secret_key")) + + login_manager = LoginManager() + login_manager.init_app(app) + + self.app = app + self.websocket = SocketIO( + app, + async_mode=self.options.get("SocketIO_asynch_mode", self.default_options.get("SocketIO_asynch_mode")), + debug=self.options.get("SocketIO_debug", self.default_options.get("SocketIO_debug")), + engineio_logger=self.options.get("engineio_logger", self.default_options.get("engineio_logger")), + use_reloader=self.options.get("SocketIO_use_reloader", self.default_options.get("SocketIO_use_reloader")), + passthrough_errors=True, + ping_timeout=WEBSOCKET_PING_TIMEOUT, + ping_interval=WEBSOCKET_PING_INTERVAL + ) + self.login_manager = login_manager + # endregion + + def get_ip(self): + s = socket(AF_INET, SOCK_DGRAM) + try: + # doesn't even have to be reachable + s.connect(('10.255.255.255', 1)) + host = s.getsockname()[0] + logger.info("ip_discovered", ip=host) + except Exception as error: + host = self.default_options.get("host") + logger.warn("ip_discovery_failed", fallback_ip=host, error=str(error)) + finally: + s.close() + return host + + def login_to_game_server(self): + """Login to game server web interface and store session cookie""" + telnet_module = loaded_modules_dict.get("module_telnet") + if not telnet_module: + logger.warn("game_server_login_telnet_missing", + reason="telnet module not loaded") + return + + game_host = telnet_module.options.get("host") + telnet_port = telnet_module.options.get("port", 8081) + web_port = telnet_port + 1 + + web_username = telnet_module.options.get("web_username", "") + web_password = telnet_module.options.get("web_password", "") + + if not web_username or not web_password: + logger.warn("game_server_login_no_credentials", + host=game_host, + port=web_port, + impact="map tiles unavailable") + return + + login_url = f'http://{game_host}:{web_port}/session/login' + + try: + response = post( + login_url, + json={"username": web_username, "password": web_password}, + headers={"Content-Type": "application/json"}, + timeout=5 + ) + + if response.status_code == 200: + sid_cookie = response.cookies.get('sid') + if sid_cookie: + self.game_server_session_id = sid_cookie + # Success - no log needed + else: + logger.warn("game_server_login_no_sid_cookie", + host=game_host, + port=web_port, + status=200) + else: + logger.error("game_server_login_failed", + host=game_host, + port=web_port, + status=response.status_code, + url=login_url) + except Exception as e: + logger.error("game_server_login_exception", + host=game_host, + port=web_port, + error=str(e), + error_type=type(e).__name__) + + def send_data_to_client(self, *args, payload=None, **kwargs): + data_type = kwargs.get("data_type", "widget_content") + target_element = kwargs.get("target_element", None) + clients = kwargs.get("clients", None) + + if all([ + clients is not None, + not isinstance(clients, KeysView), + not isinstance(clients, list) + ]): + if re.match(r"^(\d{17})$", clients): + clients = [clients] + + method = kwargs.get("method", "update") + status = kwargs.get("status", "") + + if clients is None: + clients = "all" + + with self.app.app_context(): + data_packages_to_send = [] + widget_options = { + "method": method, + "status": status, + "payload": payload, + "data_type": data_type, + "target_element": target_element, + } + + # Determine which clients to send to + if clients == "all": + # Send to all connected clients individually + # Note: broadcast=True doesn't work with self.websocket.emit() in gevent mode + clients = list(self.connected_clients.keys()) + + if clients is not None and isinstance(clients, list): + for steamid in clients: + try: + # Send to ALL socket connections for this user (multiple browsers) + user = self.connected_clients[steamid] + for socket_id in user.socket_ids: + emit_options = { + "room": socket_id + } + data_packages_to_send.append([widget_options, emit_options]) + except (AttributeError, KeyError) as error: + # User connection state is inconsistent - log and skip this client + logger.debug( + "socket_send_failed_no_client", + steamid=steamid, + data_type=data_type, + error_type=type(error).__name__, + has_client=steamid in self.connected_clients, + has_sockets=len(self.connected_clients.get(steamid, type('obj', (), {'socket_ids': []})).socket_ids) > 0 + ) + + for data_package in data_packages_to_send: + try: + self.websocket.emit( + 'data', + data_package[0], + **data_package[1] + ) + except Exception as error: + # Socket emit failed - log the error + logger.error( + "socket_emit_failed", + data_type=data_type, + error=str(error), + error_type=type(error).__name__ + ) + + def emit_event_status(self, module, event_data, recipient_steamid, status=None): + clients = recipient_steamid + + self.send_data_to_client_hook( + module, + payload=event_data, + data_type="status_message", + clients=clients, + status=status + ) + + def run(self): + # Login to game server web interface for map tile access + self.login_to_game_server() + + template_header = self.templates.get_template('frontpage/header.html') + template_frontend = self.templates.get_template('frontpage/index.html') + template_footer = self.templates.get_template('frontpage/footer.html') + + # region Management function and routes without any user-display or interaction + @self.login_manager.user_loader + def user_loader(steamid): + webserver_user = self.connected_clients.get(steamid, False) + if not webserver_user: + """ This is where the authentication will happen, see if that user in in your allowed players database or + whatever """ + webserver_user = User(steamid, time()) + self.connected_clients[steamid] = webserver_user + + return webserver_user + + @self.app.route('/login') + def login(): + steam_openid_url = 'https://steamcommunity.com/openid/login' + u = { + 'openid.ns': "http://specs.openid.net/auth/2.0", + 'openid.identity': "http://specs.openid.net/auth/2.0/identifier_select", + 'openid.claimed_id': "http://specs.openid.net/auth/2.0/identifier_select", + 'openid.mode': 'checkid_setup', + 'openid.return_to': "http://{host}:{port}/authenticate".format( + host=self.options.get("host", self.default_options.get("host")), + port=self.options.get("port", self.default_options.get("port")) + ), + 'openid.realm': "http://{host}:{port}".format( + host=self.options.get("host", self.default_options.get("host")), + port=self.options.get("port", self.default_options.get("port")) + ) + } + query_string = urlencode(u) + auth_url = "{url}?{query_string}".format( + url=steam_openid_url, + query_string=query_string + ) + return redirect(auth_url) + + @self.app.route('/authenticate', methods=['GET']) + def setup(): + def validate(signed_params): + steam_login_url_base = "https://steamcommunity.com/openid/login" + params = { + "openid.assoc_handle": signed_params["openid.assoc_handle"], + "openid.sig": signed_params["openid.sig"], + "openid.ns": signed_params["openid.ns"], + "openid.mode": "check_authentication" + } + + params_dict = signed_params.to_dict() + params_dict.update(params) + + params_dict["openid.mode"] = "check_authentication" + params_dict["openid.signed"] = params_dict["openid.signed"] + + try: + response = post(steam_login_url_base, data=params_dict) + valid_response = "is_valid:true" in response.text + except TypeError as error: + valid_response = False + + return valid_response + + if validate(request.args): + p = re.search(r"/(?P([0-9]{17}))", str(request.args["openid.claimed_id"])) + if p: + steamid = p.group("steamid") + webserver_user = User(steamid) + login_user(webserver_user, remember=True) + + return redirect("/") + + @self.app.route('/logout') + @login_required + def logout(): + # Normal logout - no log needed + self.connected_clients.pop(current_user.id, None) # Safe deletion + for module in loaded_modules_dict.values(): + module.on_socket_disconnect(current_user.id) + logout_user() + return redirect("/") + # endregion + + # region Actual routes the user gets to see and use + """ actual pages """ + @self.app.route('/unauthorized') + @self.login_manager.unauthorized_handler + def unauthorized_handler(): + return redirect("/") + + @self.app.route('/') + def protected(): + header_markup = self.template_render_hook( + self, + template=template_header, + current_user=current_user, + title=self.options.get("title", self.default_options.get("title")) + ) + footer_markup = self.template_render_hook( + self, + template=template_footer + ) + + instance_token = self.random_string(20) + template_options = { + 'current_user': current_user, + 'header': header_markup, + 'footer': footer_markup, + 'instance_token': instance_token + + } + if not current_user.is_authenticated: + main_output = ( + '
' + '

Welcome to the chrani-bot: The Next Generation

' + '

You can use your steam-account to log in!

' + '
' + ) + main_markup = Markup(main_output) + template_options['main'] = main_markup + + return self.template_render_hook( + self, + template=template_frontend, + **template_options + ) + + @self.app.route('/map_tiles///.png') + def map_tile_proxy(z, x, y): + """Proxy map tiles from game server to avoid CORS issues""" + # Parse x and y as integers (Flask's doesn't support negative numbers) + try: + x = int(x) + y = int(y) + except ValueError: + logger.warn("tile_request_invalid_coords", + z=z, x=x, y=y, + user=current_user.id if current_user.is_authenticated else "anonymous") + return Response(status=400) + + # Get game server host and port from telnet module config + telnet_module = loaded_modules_dict.get("module_telnet") + if telnet_module: + game_host = telnet_module.options.get("host", "localhost") + telnet_port = telnet_module.options.get("port", 8081) + # Web interface port is always telnet port + 1 + web_port = telnet_port + 1 + else: + game_host = "localhost" + web_port = 8082 # Default 7D2D web port + + # 7D2D uses inverted Y-axis for tiles + y_flipped = (-y) - 1 + tile_url = f'http://{game_host}:{web_port}/map/{z}/{x}/{y_flipped}.png' + + # Forward relevant headers from browser to game server + headers = {} + if request.headers.get('User-Agent'): + headers['User-Agent'] = request.headers.get('User-Agent') + if request.headers.get('Referer'): + headers['Referer'] = request.headers.get('Referer') + + # Add game server session cookie for authentication + cookies = {} + if self.game_server_session_id: + cookies['sid'] = self.game_server_session_id + + try: + response = get(tile_url, headers=headers, cookies=cookies, timeout=5) + + # Only log non-200 responses + if response.status_code != 200: + logger.error("tile_fetch_failed", + z=z, x=x, y=y, + user=current_user.id if current_user.is_authenticated else "anonymous", + status=response.status_code, + url=tile_url, + has_sid=bool(self.game_server_session_id)) + + return Response( + response.content, + status=response.status_code, + content_type='image/png' + ) + except Exception as e: + logger.error("tile_fetch_exception", + z=z, x=x, y=y, + user=current_user.id if current_user.is_authenticated else "anonymous", + url=tile_url, + error=str(e), + error_type=type(e).__name__) + return Response(status=404) + + # endregion + + # region Websocket handling + @self.websocket.on('connect') + @self.authenticated_only + def connect_handler(): + if not hasattr(request, 'sid'): + return False # not allowed here + else: + user = self.connected_clients[current_user.id] + + # Check if user already has active session(s) + if len(user.socket_ids) > 0: + # User has active session - ask if they want to take over + emit('session_conflict', { + 'existing_sessions': len(user.socket_ids), + 'message': 'Sie haben bereits eine aktive Session. Möchten Sie diese übernehmen? (Ungespeicherte Daten könnten verloren gehen)' + }, room=request.sid) + # Don't add socket yet - wait for user response + else: + # First session - connect normally + user.add_socket(request.sid) + emit('session_accepted', room=request.sid) + for module in loaded_modules_dict.values(): + module.on_socket_connect(current_user.id) + + @self.websocket.on('disconnect') + def disconnect_handler(): + # Remove this socket from the user's socket list + if current_user.is_authenticated and current_user.id in self.connected_clients: + self.connected_clients[current_user.id].remove_socket(request.sid) + + @self.websocket.on('ding') + def ding_dong(): + current_user.last_seen = time() + try: + # Use request.sid (current socket) not current_user.sid (could be another browser!) + emit('dong', room=request.sid) + + except AttributeError as error: + # user disappeared + logger.debug("client_disappeared", user=current_user.id, sid=request.sid) + + @self.websocket.on('session_takeover_accept') + @self.authenticated_only + def session_takeover_accept(): + """User accepted to take over existing session - disconnect old sessions.""" + user = self.connected_clients[current_user.id] + + # Disconnect all existing sessions + old_sockets = user.socket_ids.copy() + for old_sid in old_sockets: + # Notify old session that it's being taken over + emit('session_taken_over', { + 'message': 'Ihre Session wurde von einem anderen Browser übernommen.' + }, room=old_sid) + # Force disconnect old socket + self.websocket.server.disconnect(old_sid) + user.remove_socket(old_sid) + + # Add new session + user.add_socket(request.sid) + emit('session_accepted', room=request.sid) + + # Initialize widgets for new session + for module in loaded_modules_dict.values(): + module.on_socket_connect(current_user.id) + + logger.info("session_takeover", + user=current_user.id, + old_sessions=len(old_sockets), + new_sid=request.sid) + + @self.websocket.on('session_takeover_decline') + @self.authenticated_only + def session_takeover_decline(): + """User declined to take over - disconnect new session.""" + emit('session_declined', { + 'message': 'Session-Übernahme abgelehnt. Bitte schließen Sie die andere Session zuerst.' + }, room=request.sid) + + # Disconnect this (new) session + self.websocket.server.disconnect(request.sid) + + logger.info("session_takeover_declined", + user=current_user.id, + declined_sid=request.sid) + + @self.websocket.on('widget_event') + @self.authenticated_only + def widget_event(data): + self.dispatch_socket_event(data[0], data[1], current_user.id) + # endregion + + # Check if we're running under a WSGI server (like gunicorn) + # If so, don't start our own server thread - the WSGI server will handle it + running_under_wsgi = os.environ.get('RUNNING_UNDER_WSGI', 'false').lower() == 'true' + + if not running_under_wsgi: + # Running standalone with Flask development server + websocket_instance = Thread( + target=self.websocket.run, + args=[self.app], + kwargs={ + "host": self.options.get("host", self.default_options.get("host")), + "port": self.options.get("port", self.default_options.get("port")) + } + ) + websocket_instance.start() + + while not self.stopped.wait(self.next_cycle): + profile_start = time() + + self.trigger_action_hook(self, event_data=["logged_in_users", {}]) + + self.last_execution_time = time() - profile_start + self.next_cycle = self.run_observer_interval - self.last_execution_time + else: + # Running under WSGI server - just register routes and return + # The module will keep running in its thread for background tasks + logger.info("wsgi_mode_detected") + + +loaded_modules_dict[Webserver().get_module_identifier()] = Webserver() diff --git a/bot/modules/webserver/actions/logged_in_users.py b/bot/modules/webserver/actions/logged_in_users.py new file mode 100644 index 0000000..60a0229 --- /dev/null +++ b/bot/modules/webserver/actions/logged_in_users.py @@ -0,0 +1,42 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(*args, **kwargs): + module = args[0] + event_data = args[1] + event_data[1]["action_identifier"] = action_name + + try: + connected_clients = list(module.connected_clients.keys()) + except AttributeError: + callback_fail(*args, **kwargs) + + module.dom.data.upsert({ + module.get_module_identifier(): { + "webserver_logged_in_users": connected_clients + } + }) + + +def callback_success(*args, **kwargs): + pass + + +def callback_fail(*args, **kwargs): + pass + + +action_meta = { + "description": "gets the current list of users currently logged into the webinterface", + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/webserver/actions/toggle_webserver_status_widget_view.py b/bot/modules/webserver/actions/toggle_webserver_status_widget_view.py new file mode 100644 index 0000000..ea7f22d --- /dev/null +++ b/bot/modules/webserver/actions/toggle_webserver_status_widget_view.py @@ -0,0 +1,45 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +action_name = path.basename(path.abspath(__file__))[:-3] + + +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get("action", None) + event_data[1]["action_identifier"] = action_name + + if action == "show_options": + current_view = "options" + elif action == "show_frontend": + current_view = "frontend" + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + module.set_current_view(dispatchers_steamid, { + "current_view": current_view + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) + + +def callback_success(module, event_data, dispatchers_steamid, match=None): + pass + + +def callback_fail(module, event_data, dispatchers_steamid): + pass + + +action_meta = { + "description": ( + "Toggles the active widget-view for the webserver-widget" + ), + "main_function": main_function, + "callback_success": callback_success, + "callback_fail": callback_fail, + "requires_telnet_connection": False, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_action(action_name, action_meta) diff --git a/bot/modules/webserver/static/favicon.ico b/bot/modules/webserver/static/favicon.ico new file mode 100644 index 0000000..cb222e4 Binary files /dev/null and b/bot/modules/webserver/static/favicon.ico differ diff --git a/bot/modules/webserver/static/jquery-3.4.1.min.js b/bot/modules/webserver/static/jquery-3.4.1.min.js new file mode 100644 index 0000000..a1c07fd --- /dev/null +++ b/bot/modules/webserver/static/jquery-3.4.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0 header, body > footer { + flex: 0 0 auto; +} + +body > main { + flex: 1 1 auto; +} + +/* header and footer are special due to their graphical shoulders */ +body > header > div, body > header > div:before, +body > footer > div, body > footer > div:before { + height: var(--main_shoulder_height); +} + +body > header { + margin-bottom: var(--main_table_gutter); +} + +body > footer { + margin-top: var(--main_table_gutter); +} + +body > header > div { + position: relative; + display: flex; + align-items: flex-end; + margin: + 0 var(--main_bar_terminator_width) + 0 var(--main_shoulder_width); + overflow: hidden; +} + +body > header, +body > footer { + border-top-left-radius: calc( + var(--main_bar_height) * 2.66) + calc(var(--main_bar_height) * 1.33 + ); + background: + url(ui/main_shoulder.png) no-repeat top left, + url(ui/main_horizontal_bar_end.png) no-repeat top right; +} + +body > footer, +body > footer > div, +body > footer > div:before { + transform: scaleY(-1); +} + +body > header > div:before, +body > footer > div:before { + width: 100%; + content: ""; + background: + url(ui/main_horizontal_bar.png) + repeat-x; +} + +body > footer > div { + display: flex; + align-items: flex-end; + margin: + 0 var(--main_bar_terminator_width) + 0 var(--main_shoulder_width); + overflow: hidden; +} + +/* every element inside main > div is a widget! */ +body > main > div { + color: var(--lcars-atomic-tangerine); + display: flex; + flex-wrap: wrap; + height: var(--main_area_height); + overflow-y: scroll; /* this has to stay for scroll-snapping to work */ + overflow-x: hidden; + scroll-snap-type: y mandatory; +} + +body > main > div > .widget { + display: flex; + flex-wrap: wrap; + flex: 0 0 auto; + height: var(--main_widget_height); + scroll-snap-align: start; + border-bottom: var(--main_table_gutter) solid var(--background); + border-right: calc(var(--main_table_gutter) * 2) solid var(--background); +} + +body > main > div .single_screen { + width: 100%; + height: var(--main_area_height); + padding-left: calc(var(--main_widget_shoulder_width)); + background: linear-gradient( + to right, + var(--main-bar-color) 0, + var(--main-bar-color) calc(var(--main_widget_shoulder_width) - var(--main_shoulder_gap)), + var(--background) calc(var(--main_widget_shoulder_width) - var(--main_shoulder_gap)), + var(--background) 100% + ); +} + +body > main > div .single_screen a:hover { + text-decoration: underline; +} diff --git a/bot/modules/webserver/static/lcars/210-scrollbar_hacks.css b/bot/modules/webserver/static/lcars/210-scrollbar_hacks.css new file mode 100644 index 0000000..1424d14 --- /dev/null +++ b/bot/modules/webserver/static/lcars/210-scrollbar_hacks.css @@ -0,0 +1,15 @@ +::-webkit-scrollbar { + width: var(--main_bar_terminator_width); +} + +::-webkit-scrollbar-track { + background: var(--background); +} + +::-webkit-scrollbar-thumb { + background: var(--lcars-tanoi); + border-radius: 1.25em; +} +::-webkit-scrollbar-thumb:hover { + background: var(--lcars-atomic-tangerine); +} diff --git a/bot/modules/webserver/static/lcars/220-screen_adjustments.css b/bot/modules/webserver/static/lcars/220-screen_adjustments.css new file mode 100644 index 0000000..86a7fc5 --- /dev/null +++ b/bot/modules/webserver/static/lcars/220-screen_adjustments.css @@ -0,0 +1,19 @@ +/* This file contains some nasty style adjustments to accommodate ridiculously small screens. + * I don't care how it looks like there as long as it's usable. + */ + +@media only screen and (max-width: 960px) { + :root { + --main_widget_shoulder_width: calc( + var(--main_shoulder_width) / 2 + ) + } +} + +/* this one will remove table-cell restrictions and kinda condense the table-row. This will be a bit untidy in looks, +but more data will be available on very small screens */ +@media only screen and (max-width: 960px) { + body > main > div > .widget > main > table.data_table > tbody > tr { + display: unset; + } +} diff --git a/bot/modules/webserver/static/lcars/300-header.css b/bot/modules/webserver/static/lcars/300-header.css new file mode 100644 index 0000000..5692d15 --- /dev/null +++ b/bot/modules/webserver/static/lcars/300-header.css @@ -0,0 +1,25 @@ +@import url("310-header_widgets.css"); + +body > header > div > hgroup { + position: absolute; + right: 0; top: 0; +} + +body > header > div > hgroup > h1 { + font-size: var(--main_bar_height); + color: var(--lcars-tanoi); + background-color: var(--background); + + font-family: "SWISS 911 Ultra Compressed BT", sans-serif; + text-transform: uppercase; + padding: 0 0.25em; + + white-space: nowrap; +} + +body > header > div #header_widgets { + position: absolute; + height: calc(var(--main_shoulder_height) - var(--main_bar_height)); + right: 0; bottom: 0; left: 0; + display: flex; +} diff --git a/bot/modules/webserver/static/lcars/310-header_widgets.css b/bot/modules/webserver/static/lcars/310-header_widgets.css new file mode 100644 index 0000000..b52eb82 --- /dev/null +++ b/bot/modules/webserver/static/lcars/310-header_widgets.css @@ -0,0 +1,69 @@ +body > header > div > #header_widgets > .widget { + padding-right: 0.25em; + color: black; + padding-top: var(--main_table_gutter) +} + +body > header > div > #header_widgets > .widget > div { + background-color: var(--lcars-atomic-tangerine); + border-radius: 12px; padding: 0 1em; + white-space: nowrap; + line-height: 1.5em; +} + +body > header > div > #header_widgets > #login_logout_widget { + margin-left: auto; +} + +body > header > div > #header_widgets > #login_logout_widget a { + color: var(--background) +} + +body > header > div > #header_widgets > #gameserver_status_widget > div.active { + background-color: var(--lcars-tanoi); +} + +body > header > div > #header_widgets > #gameserver_status_widget > div.inactive { + background-color: var(--lcars-chestnut-rose); +} + +body > header > div > #header_widgets > #gameserver_status_widget span > a, +body > header > div > #header_widgets > #gameserver_status_widget span > a:visited { + color: var(--lcars-blue-bell); +} + +body > header > div > #header_widgets > #gameserver_status_widget span > a { + display: inline-block; + padding: 0 calc(var(--main_table_gutter) / 2); + border-left: calc(var(--main_table_gutter) / 2) solid var(--background); + border-right: calc(var(--main_table_gutter) / 2) solid var(--background); + color: var(--background); /* Edge seems to require this */ + background-color: var(--lcars-chestnut-rose); +} + +body > header > div > #header_widgets > #gameserver_status_widget span > a:hover, +body > header > div > #header_widgets > #gameserver_status_widget span > a:visited { + color: var(--background); + text-decoration: none; +} + +body > header > div > #header_widgets > #gameserver_status_widget span:hover > a { + background-color: var(--lcars-chestnut-rose); +} + +body > header > div > #header_widgets > #gametime_widget { + order: -1; +} + +body > header > div > #header_widgets > #gametime_widget span.time { + padding: 0 var(--main_table_gutter); +} + +body > header > div > #header_widgets > #gametime_widget span.day.bloodday, +body > header > div > #header_widgets > #gametime_widget span.time.bloodmoon { + color: var(--lcars-chestnut-rose); +} + +body > header > div > #header_widgets > #gametime_widget span.time.bloodmoon { + background-color: var(--background); +} diff --git a/bot/modules/webserver/static/lcars/400-main.css b/bot/modules/webserver/static/lcars/400-main.css new file mode 100644 index 0000000..2a7b257 --- /dev/null +++ b/bot/modules/webserver/static/lcars/400-main.css @@ -0,0 +1,12 @@ +@import url("410-main_widgets.css"); + +body > main > div #unauthorized_disclaimer p { + font-size: 1.5em; + padding-bottom: 1em; +} + +body > main > div #unauthorized_disclaimer a, +body > main > div #unauthorized_disclaimer a:visited { + color: var(--lcars-melrose); + text-decoration: None; +} diff --git a/bot/modules/webserver/static/lcars/410-main_widgets.css b/bot/modules/webserver/static/lcars/410-main_widgets.css new file mode 100644 index 0000000..df5dbb4 --- /dev/null +++ b/bot/modules/webserver/static/lcars/410-main_widgets.css @@ -0,0 +1,466 @@ +@import url("411-main_widgets_webserver_status_widget.css"); +@import url("412-main_widgets_telnet_log_widget.css"); +@import url("413-main_widgets_manage_players_widget.css"); +@import url("414-main_widgets_manage_locations_widget.css"); +@import url("415-main_widgets_manage_entities_widget.css"); + +body > main > div > .widget > main { + position: relative; +} + +body > main > div > .widget main a { + border-radius: 0.5em; + font-family: "SWISS 911 Ultra Compressed BT", sans-serif; + padding: 0 0.5em; +} + +body > main > div > .widget main a, +body > main > div > .widget main a:visited { + background-color: var(--lcars-melrose); + color: black; +} + +body > main > div > .widget main a:hover { + text-decoration: none; +} + +body > main > div > .widget main span.active a { + background-color: var(--lcars-tanoi); +} + +body > main > div > .widget main span.inactive a { + background-color: var(--lcars-chestnut-rose); +} + +body > main > div > .widget main .select_button a { + border-radius: 0; + padding: 0 0.75em; +} + +body > main > div > .widget > main > table > tbody > tr > td { + font-size: 1em; + line-height: 1.5em; + padding: 0 calc(var(--main_table_gutter) / 2); + vertical-align: middle; +} + +body > main > div > .widget > main > table > tbody > tr > td:last-child { + padding-right: var(--main_table_gutter); +} + +body > main > div > .widget > main > table > thead tr:last-child { + /* this contains the header stuff for the widget-content */ + background: var(--background); +} + +body > main > div > .widget > main > table.data_table > tbody > tr > td { + white-space: nowrap; +} + +body > main > div > .widget > main > table > tfoot > tr > td > div > span.active, +body > main > div > .widget .pull_out > div > span.active { + background-color: var(--lcars-lilac); +} + +body > main > div > .widget > main > table > tfoot > tr > td > span.inactive, +body > main > div > .widget .pull_out > div > span.inactive { + background-color: var(--lcars-hopbush); +} + +body > main > div > .widget .pull_out > div > span.info { + background-color: var(--lcars-tanoi); +} + +body > main > div > .widget .pull_out > div > span.info > div > span { + margin-left: 0.5em; + flex: 1; + text-align: left; +} + +body > main > div > .widget .pull_out span a, +body > main > div > .widget .pull_out span a:visited, +body > main > div > .widget .pull_out span > div { + text-decoration: none; + display: block; + margin-right: 0.5em; + color: var(--background); +} + +body > main > div > .widget > main .dialog { + position: absolute; + top: var(--main_table_gutter); + bottom: 5.5em; + display: none; +} + +body > main > div > .widget > main .dialog.open { + display: block; +} + +body > main > div > .widget > main .dialog .modal-content { + padding: 1em; + color: var(--lcars-blue-bell); + background-color: var(--background); + border: var(--main_table_gutter) solid var(--lcars-chestnut-rose); + + border-radius: 0 1em 0.5em 1.5em / 0 1em 0.5em 1em; + height: calc(100% + 1em); +} + +body > main > div > .widget > main .dialog .modal-content a, +body > main > div > .widget > main .dialog .modal-content p { + font-size: 2em; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal { + color: var(--lcars-tanoi); + height: 100%; + display: flex; + flex-direction: column; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal header, +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div { + text-align: center; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal header { + flex: 0 0 auto; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div { + flex: 1 1 auto; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div.dynamic_content_size { + overflow: auto; + margin: calc(var(--main_bar_height) / 2) 0; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div.dynamic_content_size::-webkit-scrollbar { + width: calc(var(--main_bar_terminator_width) / 1.66); +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div:last-child { + flex: 0 0 auto; + display: flex; +} + +body > main > div > .widget > main .dialog.open .modal-content .delete_modal div section { + flex: 1 1 auto; + width: 50%; +} + +body > main > div > .widget > main > table > tbody { + display: block; + overflow: auto; + height: calc( + var(--main_widget_height) + - var(--main_widget_bar_height) + - var(--main_table_caption_height) + ); + padding-right: 0.5em; +} + +body > main > div > .widget > main > table.data_table > tbody { + height: calc( + var(--main_widget_height) + - var(--main_widget_bar_height) + - var(--main_table_header_height) + - var(--main_table_footer_height) + - var(--main_table_caption_height) + ); + + max-width: calc( + 100vw + - var(--main_widget_shoulder_width) + - var(--main_bar_terminator_width) + - 1em + ); +} + +body > main > div > .widget > main > table.data_table > tbody tr { + max-width: calc( + 100vw + - var(--main_widget_shoulder_width) + - var(--main_bar_terminator_width) + - 1em + - var(--main_bar_terminator_width) + ); +} + +body > main > div > .widget > main > table > thead > tr { + display: none; +} + +body > main > div > .widget > main > table.data_table > thead > tr { + display: inline-block; + width: 100%; + height: var(--main_table_header_height); + text-align: right; +} + +body > main > div > .widget > main > table.data_table > thead > tr > th { + display: inline-block; + margin: var(--main_table_gutter); + height: calc( + var(--main_table_header_height) + - (var(--main_table_gutter) * 2) + ); + line-height: calc( + var(--main_table_header_height) + - (var(--main_table_gutter) * 2) + ); +} + +body > main > div > .widget > main > table.data_table > thead > tr > th:last-child { + margin-right: var(--main_bar_terminator_width); +} + +body > main > div > .widget > main table.box_input, +body > main > div > .widget > main table.box_select { + margin-bottom: 1.5em; +} + +body > main > div > .widget > main table.box_select tfoot td, +body > main > div > .widget > main table.box_input tfoot td { + white-space: normal; +} + +body > main > div > .widget > main table.box_select input[disabled], +body > main > div > .widget > main table.box_input input[disabled] { + background-color: var(--lcars-chestnut-rose); +} + +body > main > div > .widget > main table.box_select tfoot tr td, +body > main > div > .widget > main table.box_input tfoot tr td { + color: var(--background); + padding: 0.5em 0; +} + +body > main > div > .widget > main table.box_select tfoot tr td > div, +body > main > div > .widget > main table.box_input tfoot tr td > div { + /* background-image: linear-gradient(to right, var(--background), var(--lcars-melrose), var(--lcars-melrose), var(--lcars-melrose)); */ + background-color: var(--lcars-melrose); + text-align: right; + line-height: 1em; + font-size: 0.9em; + padding: 0.8em; + /* display: none; */ +} + +body > main > div > .widget > main table.box_select td { + width: 50%; +} + +/* checkbox styling */ +body > main > div > .widget > main table.box_select label.slider { + position: relative; + cursor: pointer; +} + +body > main > div > .widget > main table.box_select label.slider [type="checkbox"] { + display: none; +} + +body > main > div > .widget > main table.box_select .slider [type="checkbox"] + span { + color: var(--background); + display: block; + background: var(--lcars-chestnut-rose); + padding: var(--main_table_gutter); + margin: var(--main_table_gutter); + border-radius: 0.5em; +} + +body > main > div > .widget > main table.box_select .slider:hover [type="checkbox"] + span, +body > main > div > .widget > main table.box_select .slider :checked + span { + background: var(--lcars-tanoi); +} + +body > main > div > .widget > main table.box_select .slider [type="checkbox"][disabled] + span { + background: var(--lcars-orange-peel); +} + +body > main > div > .widget > main > table > thead { + max-height: var(--main_table_header_height); +} + +body > main > div > .widget > main > table > tfoot { + max-height: var(--main_table_footer_height); +} + +body > main > div > .widget > main > table > caption { + /* display: none; */ + max-height: var(--main_table_caption_height); + border-top: 0.5em solid var(--background); + margin-right: calc( + var(--main_bar_terminator_width) / 2 + ); + overflow: hidden; +} + +body > main > div > .widget > header { + width: 100%; + background-color: var(--lcars-hopbush); + border-radius: 1.5em 1.5em 1.5em 0; + height: var(--main_bar_height); +} + +body > main > div > .widget > header > div > span { + font-size: var(--main_widget_bar_height); + line-height: 1em; + color: var(--background); + margin-left: 1.5em; +} + +body > main > div > .widget > aside { + flex: 0 0 var(--main_widget_shoulder_width); + background: linear-gradient( + to right, + var(--main-bar-color) 0, + var(--main-bar-color) calc(var(--main_widget_shoulder_width) - var(--main_shoulder_gap)), + var(--background) calc(var(--main_widget_shoulder_width) - var(--main_shoulder_gap)), + var(--background) 100% + ); + height: calc( + var(--main_widget_height) - var(--main_widget_bar_height) + ); +} + +body > main > div > .widget > main { + flex: 1 0 calc( + 100% + - var(--main_widget_shoulder_width) + ); + overflow-y: auto; + height: calc( + var(--main_widget_height) - var(--main_widget_bar_height) + ); +} + +body > main > div > .widget > aside > div { + width: var(--main_widget_shoulder_width); +} + +body > main > div > .widget > aside > div > div { + border-top: var(--main_table_gutter) solid var(--background); + background-color: var(--background); +} + +body > main > div > .widget > aside > div > div:last-child { + border-bottom: var(--main_table_gutter) solid var(--background); +} + +body > main > div > .widget > aside > div > div > span { + display: block; + background-color: var(--lcars-hopbush); + margin-right: var(--main_shoulder_gap); + line-height: var(--main_shoulder_height); +} + +body > main > div > .widget > aside > div > div > span { + border-radius: var(--main_table_gutter); +} + +body > main > div > .widget > aside > div > div > span.info { + border-radius: unset; + line-height: calc(var(--main_shoulder_height) / 2); +} + +body > main > div > .widget > main > table > tfoot > tr { + height: var(--main_table_footer_height); + line-height: var(--main_table_footer_height); +} + +body > main > div > .widget > main > table > tfoot > tr > td { + height: calc( + var(--main_table_footer_height) + - var(--main_table_gutter) + ); + line-height: calc( + var(--main_table_footer_height) + - var(--main_table_gutter) + ); + vertical-align: bottom; +} + +body > main > div > .widget > main > table > tfoot > tr > td > div { + background-color: var(--lcars-tanoi); + padding-left: calc(var(--main_shoulder_width) / 2); + border-radius: 0 1em 1em 2em / 0 1em 1em 2em; +} + +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button { + display: inline-block; + border-left: 0.25em solid var(--background); + border-right: 0.25em solid var(--background); +} + +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span a, +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span { + display: inline-block; +} + +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span.active a, +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span.active { + background-color: var(--lcars-chestnut-rose); +} + +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span.inactive a, +body > main > div > .widget main > table > tfoot > tr > td > div .delete_button span.inactive { + background-color: var(--lcars-tanoi); +} + +body > main > div > .widget > main tr td[id$="_actions"] > span { + padding-right: var(--main_table_gutter); +} + +body > main > div > .widget > main tr td[id$="_actions"] > span:last-child { + padding-right: 0; +} + +/* making the standard order the middle instead of 0 */ +body > main > div > .widget { + order: 500; +} + +body > main > div > #manage_players_widget { + order: -4; + flex: 1 0 calc( + 960px + - var(--main_table_gutter) * 2 + - var(--main_bar_terminator_width) + ); +} + +body > main > div > #webserver_status_widget { + order: -3; + flex: 1 0 calc( + 480px + - var(--main_table_gutter) * 2 + ); +} + +body > main > div > #manage_locations_widget { + order: -2; + flex: 1 0 calc( + 860px + - var(--main_table_gutter) * 2 + ); +} + +body > main > div > #manage_entities_widget { + order: -1; + flex: 1 0 calc( + 768px + - var(--main_table_gutter) * 2 + ); +} + +body > main > div > #telnet_log_widget { + order: 999; + flex: 1 0 calc( + 768px + - var(--main_table_gutter) * 2 + ); +} diff --git a/bot/modules/webserver/static/lcars/411-main_widgets_webserver_status_widget.css b/bot/modules/webserver/static/lcars/411-main_widgets_webserver_status_widget.css new file mode 100644 index 0000000..e69de29 diff --git a/bot/modules/webserver/static/lcars/412-main_widgets_telnet_log_widget.css b/bot/modules/webserver/static/lcars/412-main_widgets_telnet_log_widget.css new file mode 100644 index 0000000..e6fe394 --- /dev/null +++ b/bot/modules/webserver/static/lcars/412-main_widgets_telnet_log_widget.css @@ -0,0 +1,30 @@ +body > main > div > #telnet_log_widget { + margin-bottom: 0; + width: 100%; +} + +body > main > div > #telnet_log_widget .log_line { + /* log entries seem to be traditionally blue in LCARS ^^ */ + color: var(--lcars-blue-bell); +} + +body > main > div > #telnet_log_widget .log_line td { + white-space: normal; + padding-left: calc(var(--main_shoulder_width) / 3); + text-indent: calc(-1 * calc(var(--main_shoulder_width) / 3)); +} + +body > main > div > #telnet_log_widget tr.game_chat, +body > main > div > #telnet_log_widget caption span.game_chat { + color: var(--lcars-hopbush); +} + +body > main > div > #telnet_log_widget tr.player_logged, +body > main > div > #telnet_log_widget caption span.player_logged { + color: var(--lcars-anakiwa); +} + +body > main > div > #telnet_log_widget tr.bot_command, +body > main > div > #telnet_log_widget caption span.bot_command { + color: var(--lcars-cosmic); +} diff --git a/bot/modules/webserver/static/lcars/413-main_widgets_manage_players_widget.css b/bot/modules/webserver/static/lcars/413-main_widgets_manage_players_widget.css new file mode 100644 index 0000000..47c09aa --- /dev/null +++ b/bot/modules/webserver/static/lcars/413-main_widgets_manage_players_widget.css @@ -0,0 +1,92 @@ +body > main > div > #manage_players_widget > main > table > tbody > tr > td[id$='_name'] { + width: 100%; +} + +body > main > div > #manage_players_widget tbody#player_table > tr:hover * { + background-color: var(--lcars-tanoi); + color: var(--background) +} + +/* player status */ +/* offline */ +body > main > div > #manage_players_widget caption span:not(.is_online):not(.is_initialized), +body > main > div > #manage_players_widget tbody > span:not(.is_online):not(.is_initialized), +body > main > div > #manage_players_widget tbody > tr:not(.is_online):not(.is_initialized) { + color: var(--lcars-chestnut-rose); +} + +/* offline and dead */ +body > main > div > #manage_players_widget caption span.in_limbo:not(.is_online):not(.is_initialized):not(.has_health), +body > main > div > #manage_players_widget tbody > span.in_limbo:not(.is_online):not(.is_initialized):not(.has_health), +body > main > div > #manage_players_widget tbody > tr.in_limbo:not(.is_online):not(.is_initialized):not(.has_health) { + color: var(--lcars-chestnut-rose); +} +/* special fading animation for offline players currently dead */ +body > main > div > #manage_players_widget caption span.in_limbo:not(.is_online):not(.is_initialized):not(.has_health), +body > main > div > #manage_players_widget tbody > span.in_limbo:not(.is_online):not(.is_initialized):not(.has_health) td:nth-child(n+3), +body > main > div > #manage_players_widget tbody > tr.in_limbo:not(.is_online):not(.is_initialized):not(.has_health) td:nth-child(n+3) { + animation: blinker 4s linear infinite; +} + +/* online and logging in */ +body > main > div > #manage_players_widget caption span.is_online:not(.is_initialized).in_limbo, +body > main > div > #manage_players_widget tbody > span.is_online:not(.is_initialized).in_limbo, +body > main > div > #manage_players_widget tbody > tr.is_online:not(.is_initialized).in_limbo { + color: var(--lcars-tanoi); +} +/* special fading animation for players currently logging in */ +body > main > div > #manage_players_widget caption span.is_online:not(.is_initialized).in_limbo, +body > main > div > #manage_players_widget tbody > span.is_online:not(.is_initialized).in_limbo td:nth-child(n+3), +body > main > div > #manage_players_widget tbody > tr.is_online:not(.is_initialized).in_limbo td:nth-child(n+3) { + animation: blinker 3s linear infinite; +} + +/* online */ +body > main > div > #manage_players_widget caption span.is_online.is_initialized:not(.in_limbo), +body > main > div > #manage_players_widget tbody > span.is_online.is_initialized:not(.in_limbo), +body > main > div > #manage_players_widget tbody > tr.is_online.is_initialized:not(.in_limbo) { + color: var(--lcars-tanoi); +} +/* online and dead */ +body > main > div > #manage_players_widget caption span.is_online.is_initialized.in_limbo, +body > main > div > #manage_players_widget tbody > span.is_online.is_initialized.in_limbo, +body > main > div > #manage_players_widget tbody > tr.is_online.is_initialized.in_limbo { + color: var(--lcars-atomic-tangerine); +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_id"], +body > main > div > #manage_players_widget tr[id^="player_table_row_"] td[class="position"], +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_ping"], +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_last_updated_servertime"], +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_last_seen_gametime"] { + font-size: 0.90em; +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_name"] { + max-width: 10em; +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_health"]:before { + content: "\2665"; + padding-right: var(--main_table_gutter); + padding-left: calc(var(--main_table_gutter) * 2); + color: var(--lcars-chestnut-rose); +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_pos"] span { + width: 1.5em; + display: inline-block; +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_pos"]:before { + padding-left: calc(var(--main_table_gutter) * 2); + color: var(--lcars-chestnut-rose); + content: "\2691"; +} + +body > main > div > #manage_players_widget td[id^="player_table_row_"][id$="_zombies"]:before { + color: var(--lcars-chestnut-rose); + content: "\2620"; + padding-right: var(--main_table_gutter); + padding-left: calc(var(--main_table_gutter) * 2); +} diff --git a/bot/modules/webserver/static/lcars/414-main_widgets_manage_locations_widget.css b/bot/modules/webserver/static/lcars/414-main_widgets_manage_locations_widget.css new file mode 100644 index 0000000..08df077 --- /dev/null +++ b/bot/modules/webserver/static/lcars/414-main_widgets_manage_locations_widget.css @@ -0,0 +1,52 @@ +body > main > div > #manage_locations_widget > main > table > tbody > tr > td[id$='_name'] { + width: 100%; +} + +body > main > div > #manage_locations_widget tbody#location_table > tr:hover * { + background-color: var(--lcars-tanoi); + color: var(--background) +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div { + display: flex; + justify-content: flex-end; +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div > div { + margin-right: 0.5em; +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div > div:last-child { + margin-right: 0; +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div > label { + flex: 0; + white-space: nowrap; + margin-right: 0.5em; +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div > label:last-child { + margin-right: 0; +} + +body > main > div > #manage_locations_widget #current_player_pos > span > div > label input { + border: 0; + display: inline-block; + margin: 0; padding: 0; + width: 2.5em; + text-align: right; + background-color: transparent; +} + +body > main > div > main > div > #manage_locations_widget #current_player_pos > span > div > label:nth-child(2) input { + width: 2em; +} + +body > main > div > main > div > #manage_locations_widget td[id^="location_table_row_"][id$="_id"], +body > main > div > main > div > #manage_locations_widget td[id^="location_table_row_"][id$="_last_changed"], +body > main > div > main > div > #manage_locations_widget td[id^="location_table_row_"][id$="_coordinates"], +body > main > div > main > div > #manage_locations_widget td[id^="location_table_row_"][id$="_owner"] { + font-size: 0.90em; + vertical-align: middle; +} diff --git a/bot/modules/webserver/static/lcars/415-main_widgets_manage_entities_widget.css b/bot/modules/webserver/static/lcars/415-main_widgets_manage_entities_widget.css new file mode 100644 index 0000000..7a3aa80 --- /dev/null +++ b/bot/modules/webserver/static/lcars/415-main_widgets_manage_entities_widget.css @@ -0,0 +1,8 @@ +body > main > div > #manage_entities_widget > main > table > tbody > tr > td[id$='_name'] { + width: 100%; +} + +body > main > div > #manage_entities_widget tbody#entity_table > tr:hover * { + background-color: var(--lcars-tanoi); + color: var(--background) +} diff --git a/bot/modules/webserver/static/lcars/500-footer.css b/bot/modules/webserver/static/lcars/500-footer.css new file mode 100644 index 0000000..1293659 --- /dev/null +++ b/bot/modules/webserver/static/lcars/500-footer.css @@ -0,0 +1,6 @@ +@import url("310-header_widgets.css"); + +footer > div > p { + position: absolute; + right: 0; +} diff --git a/bot/modules/webserver/static/lcars/510-footer_widgets.css b/bot/modules/webserver/static/lcars/510-footer_widgets.css new file mode 100644 index 0000000..e69de29 diff --git a/bot/modules/webserver/static/lcars/audio/alarm01.mp3 b/bot/modules/webserver/static/lcars/audio/alarm01.mp3 new file mode 100644 index 0000000..2b686e1 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/alarm01.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/alarm03.mp3 b/bot/modules/webserver/static/lcars/audio/alarm03.mp3 new file mode 100644 index 0000000..36e4cb4 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/alarm03.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/alert12.mp3 b/bot/modules/webserver/static/lcars/audio/alert12.mp3 new file mode 100644 index 0000000..f5c4b45 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/alert12.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/computer_error.mp3 b/bot/modules/webserver/static/lcars/audio/computer_error.mp3 new file mode 100644 index 0000000..dd07c62 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/computer_error.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/computer_work_beep.mp3 b/bot/modules/webserver/static/lcars/audio/computer_work_beep.mp3 new file mode 100644 index 0000000..6dca550 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/computer_work_beep.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/computerbeep_11.mp3 b/bot/modules/webserver/static/lcars/audio/computerbeep_11.mp3 new file mode 100644 index 0000000..d4cc9b2 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/computerbeep_11.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/computerbeep_38.mp3 b/bot/modules/webserver/static/lcars/audio/computerbeep_38.mp3 new file mode 100644 index 0000000..b50a130 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/computerbeep_38.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/computerbeep_65.mp3 b/bot/modules/webserver/static/lcars/audio/computerbeep_65.mp3 new file mode 100644 index 0000000..74f7e72 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/computerbeep_65.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/input_ok_2_clean.mp3 b/bot/modules/webserver/static/lcars/audio/input_ok_2_clean.mp3 new file mode 100644 index 0000000..7676e07 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/input_ok_2_clean.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/keyok1.mp3 b/bot/modules/webserver/static/lcars/audio/keyok1.mp3 new file mode 100644 index 0000000..2908a4f Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/keyok1.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/processing.mp3 b/bot/modules/webserver/static/lcars/audio/processing.mp3 new file mode 100644 index 0000000..6e23a5d Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/processing.mp3 differ diff --git a/bot/modules/webserver/static/lcars/audio/scrscroll3.mp3 b/bot/modules/webserver/static/lcars/audio/scrscroll3.mp3 new file mode 100644 index 0000000..176df56 Binary files /dev/null and b/bot/modules/webserver/static/lcars/audio/scrscroll3.mp3 differ diff --git a/bot/modules/webserver/static/lcars/fonts/SWISS911ExtraCompressedBT.ttf b/bot/modules/webserver/static/lcars/fonts/SWISS911ExtraCompressedBT.ttf new file mode 100644 index 0000000..092e768 Binary files /dev/null and b/bot/modules/webserver/static/lcars/fonts/SWISS911ExtraCompressedBT.ttf differ diff --git a/bot/modules/webserver/static/lcars/fonts/SWISS911UltraCompressedBT.ttf b/bot/modules/webserver/static/lcars/fonts/SWISS911UltraCompressedBT.ttf new file mode 100644 index 0000000..2c9204f Binary files /dev/null and b/bot/modules/webserver/static/lcars/fonts/SWISS911UltraCompressedBT.ttf differ diff --git a/bot/modules/webserver/static/lcars/ui/main_horizontal_bar.png b/bot/modules/webserver/static/lcars/ui/main_horizontal_bar.png new file mode 100644 index 0000000..e717366 Binary files /dev/null and b/bot/modules/webserver/static/lcars/ui/main_horizontal_bar.png differ diff --git a/bot/modules/webserver/static/lcars/ui/main_horizontal_bar_end.png b/bot/modules/webserver/static/lcars/ui/main_horizontal_bar_end.png new file mode 100644 index 0000000..7b90cfc Binary files /dev/null and b/bot/modules/webserver/static/lcars/ui/main_horizontal_bar_end.png differ diff --git a/bot/modules/webserver/static/lcars/ui/main_shoulder.png b/bot/modules/webserver/static/lcars/ui/main_shoulder.png new file mode 100644 index 0000000..fc117d8 Binary files /dev/null and b/bot/modules/webserver/static/lcars/ui/main_shoulder.png differ diff --git a/bot/modules/webserver/static/leaflet.css b/bot/modules/webserver/static/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/bot/modules/webserver/static/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/bot/modules/webserver/static/leaflet.js b/bot/modules/webserver/static/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/bot/modules/webserver/static/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&10&&!this.encoding){var t=this.packetBuffer.shift();this.packet(t)}},n.prototype.cleanup=function(){for(var t=this.subs.length,e=0;e=this._reconnectionAttempts)this.backoff.reset(),this.emitAll("reconnect_failed"),this.reconnecting=!1;else{var e=this.backoff.duration();this.reconnecting=!0;var r=setTimeout(function(){t.skipReconnect||(t.emitAll("reconnect_attempt",t.backoff.attempts),t.emitAll("reconnecting",t.backoff.attempts),t.skipReconnect||t.open(function(e){e?(t.reconnecting=!1,t.reconnect(),t.emitAll("reconnect_error",e.data)):t.onreconnect()}))},e);this.subs.push({destroy:function(){clearTimeout(r)}})}},n.prototype.onreconnect=function(){var t=this.backoff.attempts;this.reconnecting=!1,this.backoff.reset(),this.updateSocketIds(),this.emitAll("reconnect",t)}},function(t,e,r){t.exports=r(11),t.exports.parser=r(18)},function(t,e,r){function n(t,e){return this instanceof n?(e=e||{},t&&"object"==typeof t&&(e=t,t=null),t?(t=p(t),e.hostname=t.host,e.secure="https"===t.protocol||"wss"===t.protocol,e.port=t.port,t.query&&(e.query=t.query)):e.host&&(e.hostname=p(e.host).host),this.secure=null!=e.secure?e.secure:"undefined"!=typeof location&&"https:"===location.protocol,e.hostname&&!e.port&&(e.port=this.secure?"443":"80"),this.agent=e.agent||!1,this.hostname=e.hostname||("undefined"!=typeof location?location.hostname:"localhost"),this.port=e.port||("undefined"!=typeof location&&location.port?location.port:this.secure?443:80),this.query=e.query||{},"string"==typeof this.query&&(this.query=h.decode(this.query)),this.upgrade=!1!==e.upgrade,this.path=(e.path||"/engine.io").replace(/\/$/,"")+"/",this.forceJSONP=!!e.forceJSONP,this.jsonp=!1!==e.jsonp,this.forceBase64=!!e.forceBase64,this.enablesXDR=!!e.enablesXDR,this.withCredentials=!1!==e.withCredentials,this.timestampParam=e.timestampParam||"t",this.timestampRequests=e.timestampRequests,this.transports=e.transports||["polling","websocket"],this.transportOptions=e.transportOptions||{},this.readyState="",this.writeBuffer=[],this.prevBufferLen=0,this.policyPort=e.policyPort||843,this.rememberUpgrade=e.rememberUpgrade||!1,this.binaryType=null,this.onlyBinaryUpgrades=e.onlyBinaryUpgrades,this.perMessageDeflate=!1!==e.perMessageDeflate&&(e.perMessageDeflate||{}),!0===this.perMessageDeflate&&(this.perMessageDeflate={}),this.perMessageDeflate&&null==this.perMessageDeflate.threshold&&(this.perMessageDeflate.threshold=1024),this.pfx=e.pfx||null,this.key=e.key||null,this.passphrase=e.passphrase||null,this.cert=e.cert||null,this.ca=e.ca||null,this.ciphers=e.ciphers||null,this.rejectUnauthorized=void 0===e.rejectUnauthorized||e.rejectUnauthorized,this.forceNode=!!e.forceNode,this.isReactNative="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),("undefined"==typeof self||this.isReactNative)&&(e.extraHeaders&&Object.keys(e.extraHeaders).length>0&&(this.extraHeaders=e.extraHeaders),e.localAddress&&(this.localAddress=e.localAddress)),this.id=null,this.upgrades=null,this.pingInterval=null,this.pingTimeout=null,this.pingIntervalTimer=null,this.pingTimeoutTimer=null,void this.open()):new n(t,e)}function o(t){var e={};for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r]);return e}var i=r(12),s=r(5),a=(r(3)("engine.io-client:socket"),r(32)),c=r(18),p=r(2),h=r(26);t.exports=n,n.priorWebsocketSuccess=!1,s(n.prototype),n.protocol=c.protocol,n.Socket=n,n.Transport=r(17),n.transports=r(12),n.parser=r(18),n.prototype.createTransport=function(t){var e=o(this.query);e.EIO=c.protocol,e.transport=t;var r=this.transportOptions[t]||{};this.id&&(e.sid=this.id);var n=new i[t]({query:e,socket:this,agent:r.agent||this.agent,hostname:r.hostname||this.hostname,port:r.port||this.port,secure:r.secure||this.secure,path:r.path||this.path,forceJSONP:r.forceJSONP||this.forceJSONP,jsonp:r.jsonp||this.jsonp,forceBase64:r.forceBase64||this.forceBase64,enablesXDR:r.enablesXDR||this.enablesXDR,withCredentials:r.withCredentials||this.withCredentials,timestampRequests:r.timestampRequests||this.timestampRequests,timestampParam:r.timestampParam||this.timestampParam,policyPort:r.policyPort||this.policyPort,pfx:r.pfx||this.pfx,key:r.key||this.key,passphrase:r.passphrase||this.passphrase,cert:r.cert||this.cert,ca:r.ca||this.ca,ciphers:r.ciphers||this.ciphers,rejectUnauthorized:r.rejectUnauthorized||this.rejectUnauthorized,perMessageDeflate:r.perMessageDeflate||this.perMessageDeflate,extraHeaders:r.extraHeaders||this.extraHeaders,forceNode:r.forceNode||this.forceNode,localAddress:r.localAddress||this.localAddress,requestTimeout:r.requestTimeout||this.requestTimeout,protocols:r.protocols||void 0,isReactNative:this.isReactNative});return n},n.prototype.open=function(){var t;if(this.rememberUpgrade&&n.priorWebsocketSuccess&&this.transports.indexOf("websocket")!==-1)t="websocket";else{if(0===this.transports.length){var e=this;return void setTimeout(function(){e.emit("error","No transports available")},0)}t=this.transports[0]}this.readyState="opening";try{t=this.createTransport(t)}catch(t){return this.transports.shift(),void this.open()}t.open(),this.setTransport(t)},n.prototype.setTransport=function(t){var e=this;this.transport&&this.transport.removeAllListeners(),this.transport=t,t.on("drain",function(){e.onDrain()}).on("packet",function(t){e.onPacket(t)}).on("error",function(t){e.onError(t)}).on("close",function(){e.onClose("transport close")})},n.prototype.probe=function(t){function e(){if(u.onlyBinaryUpgrades){var t=!this.supportsBinary&&u.transport.supportsBinary;h=h||t}h||(p.send([{type:"ping",data:"probe"}]),p.once("packet",function(t){if(!h)if("pong"===t.type&&"probe"===t.data){if(u.upgrading=!0,u.emit("upgrading",p),!p)return;n.priorWebsocketSuccess="websocket"===p.name,u.transport.pause(function(){h||"closed"!==u.readyState&&(c(),u.setTransport(p),p.send([{type:"upgrade"}]),u.emit("upgrade",p),p=null,u.upgrading=!1,u.flush())})}else{var e=new Error("probe error");e.transport=p.name,u.emit("upgradeError",e)}}))}function r(){h||(h=!0,c(),p.close(),p=null)}function o(t){var e=new Error("probe error: "+t);e.transport=p.name,r(),u.emit("upgradeError",e)}function i(){o("transport closed")}function s(){o("socket closed")}function a(t){p&&t.name!==p.name&&r()}function c(){p.removeListener("open",e),p.removeListener("error",o),p.removeListener("close",i),u.removeListener("close",s),u.removeListener("upgrading",a)}var p=this.createTransport(t,{probe:1}),h=!1,u=this;n.priorWebsocketSuccess=!1,p.once("open",e),p.once("error",o),p.once("close",i),this.once("close",s),this.once("upgrading",a),p.open()},n.prototype.onOpen=function(){if(this.readyState="open",n.priorWebsocketSuccess="websocket"===this.transport.name,this.emit("open"),this.flush(),"open"===this.readyState&&this.upgrade&&this.transport.pause)for(var t=0,e=this.upgrades.length;t1?{type:b[o],data:t.substring(1)}:{type:b[o]}:k}var i=new Uint8Array(t),o=i[0],s=f(t,1);return w&&"blob"===r&&(s=new w([s])),{type:b[o],data:s}},e.decodeBase64Packet=function(t,e){var r=b[t.charAt(0)];if(!p)return{type:r,data:{base64:!0,data:t.substr(1)}};var n=p.decode(t.substr(1));return"blob"===e&&w&&(n=new w([n])),{type:r,data:n}},e.encodePayload=function(t,r,n){function o(t){return t.length+":"+t}function i(t,n){e.encodePacket(t,!!s&&r,!1,function(t){n(null,o(t))})}"function"==typeof r&&(n=r,r=null);var s=u(t);return r&&s?w&&!g?e.encodePayloadAsBlob(t,n):e.encodePayloadAsArrayBuffer(t,n):t.length?void c(t,i,function(t,e){return n(e.join(""))}):n("0:")},e.decodePayload=function(t,r,n){if("string"!=typeof t)return e.decodePayloadAsBinary(t,r,n);"function"==typeof r&&(n=r,r=null);var o;if(""===t)return n(k,0,1);for(var i,s,a="",c=0,p=t.length;c0;){for(var s=new Uint8Array(o),a=0===s[0],c="",p=1;255!==s[p];p++){if(c.length>310)return n(k,0,1);c+=s[p]}o=f(o,2+c.length),c=parseInt(c);var h=f(o,0,c);if(a)try{h=String.fromCharCode.apply(null,new Uint8Array(h))}catch(t){var u=new Uint8Array(h);h="";for(var p=0;pn&&(r=n),e>=n||e>=r||0===n)return new ArrayBuffer(0);for(var o=new Uint8Array(t),i=new Uint8Array(r-e),s=e,a=0;s=55296&&e<=56319&&o65535&&(e-=65536,o+=d(e>>>10&1023|55296),e=56320|1023&e),o+=d(e);return o}function o(t,e){if(t>=55296&&t<=57343){if(e)throw Error("Lone surrogate U+"+t.toString(16).toUpperCase()+" is not a scalar value");return!1}return!0}function i(t,e){return d(t>>e&63|128)}function s(t,e){if(0==(4294967168&t))return d(t);var r="";return 0==(4294965248&t)?r=d(t>>6&31|192):0==(4294901760&t)?(o(t,e)||(t=65533),r=d(t>>12&15|224),r+=i(t,6)):0==(4292870144&t)&&(r=d(t>>18&7|240),r+=i(t,12),r+=i(t,6)),r+=d(63&t|128)}function a(t,e){e=e||{};for(var n,o=!1!==e.strict,i=r(t),a=i.length,c=-1,p="";++c=f)throw Error("Invalid byte index");var t=255&u[l];if(l++,128==(192&t))return 63&t;throw Error("Invalid continuation byte")}function p(t){var e,r,n,i,s;if(l>f)throw Error("Invalid byte index");if(l==f)return!1;if(e=255&u[l],l++,0==(128&e))return e;if(192==(224&e)){if(r=c(),s=(31&e)<<6|r,s>=128)return s;throw Error("Invalid continuation byte")}if(224==(240&e)){if(r=c(),n=c(),s=(15&e)<<12|r<<6|n,s>=2048)return o(s,t)?s:65533;throw Error("Invalid continuation byte")}if(240==(248&e)&&(r=c(),n=c(),i=c(),s=(7&e)<<18|r<<12|n<<6|i,s>=65536&&s<=1114111))return s;throw Error("Invalid UTF-8 detected")}function h(t,e){e=e||{};var o=!1!==e.strict;u=r(t),f=u.length,l=0;for(var i,s=[];(i=p(o))!==!1;)s.push(i);return n(s)}/*! https://mths.be/utf8js v2.1.2 by @mathias */ +var u,f,l,d=String.fromCharCode;t.exports={version:"2.1.2",encode:a,decode:h}},function(t,e){!function(){"use strict";for(var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",r=new Uint8Array(256),n=0;n>2],i+=t[(3&n[r])<<4|n[r+1]>>4],i+=t[(15&n[r+1])<<2|n[r+2]>>6],i+=t[63&n[r+2]];return o%3===2?i=i.substring(0,i.length-1)+"=":o%3===1&&(i=i.substring(0,i.length-2)+"=="),i},e.decode=function(t){var e,n,o,i,s,a=.75*t.length,c=t.length,p=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var h=new ArrayBuffer(a),u=new Uint8Array(h);for(e=0;e>4,u[p++]=(15&o)<<4|i>>2,u[p++]=(3&i)<<6|63&s;return h}}()},function(t,e){function r(t){return t.map(function(t){if(t.buffer instanceof ArrayBuffer){var e=t.buffer;if(t.byteLength!==e.byteLength){var r=new Uint8Array(t.byteLength);r.set(new Uint8Array(e,t.byteOffset,t.byteLength)),e=r.buffer}return e}return t})}function n(t,e){e=e||{};var n=new i;return r(t).forEach(function(t){n.append(t)}),e.type?n.getBlob(e.type):n.getBlob()}function o(t,e){return new Blob(r(t),e||{})}var i="undefined"!=typeof i?i:"undefined"!=typeof WebKitBlobBuilder?WebKitBlobBuilder:"undefined"!=typeof MSBlobBuilder?MSBlobBuilder:"undefined"!=typeof MozBlobBuilder&&MozBlobBuilder,s=function(){try{var t=new Blob(["hi"]);return 2===t.size}catch(t){return!1}}(),a=s&&function(){try{var t=new Blob([new Uint8Array([1,2])]);return 2===t.size}catch(t){return!1}}(),c=i&&i.prototype.append&&i.prototype.getBlob;"undefined"!=typeof Blob&&(n.prototype=Blob.prototype,o.prototype=Blob.prototype),t.exports=function(){return s?a?Blob:o:c?n:void 0}()},function(t,e){e.encode=function(t){var e="";for(var r in t)t.hasOwnProperty(r)&&(e.length&&(e+="&"),e+=encodeURIComponent(r)+"="+encodeURIComponent(t[r]));return e},e.decode=function(t){for(var e={},r=t.split("&"),n=0,o=r.length;n0);return e}function n(t){var e=0;for(h=0;h';i=document.createElement(t)}catch(t){i=document.createElement("iframe"),i.name=o.iframeId,i.src="javascript:0"}i.id=o.iframeId,o.form.appendChild(i),o.iframe=i}var o=this;if(!this.form){var i,s=document.createElement("form"),a=document.createElement("textarea"),c=this.iframeId="eio_iframe_"+this.index;s.className="socketio",s.style.position="absolute",s.style.top="-1000px",s.style.left="-1000px",s.target=c,s.method="POST",s.setAttribute("accept-charset","utf-8"),a.name="d",s.appendChild(a),document.body.appendChild(s),this.form=s,this.area=a}this.form.action=this.uri(),n(),t=t.replace(h,"\\\n"),this.area.value=t.replace(p,"\\n");try{this.form.submit()}catch(t){}this.iframe.attachEvent?this.iframe.onreadystatechange=function(){"complete"===o.iframe.readyState&&r()}:this.iframe.onload=r}}).call(e,function(){return this}())},function(t,e,r){function n(t){var e=t&&t.forceBase64;e&&(this.supportsBinary=!1),this.perMessageDeflate=t.perMessageDeflate,this.usingBrowserWebSocket=o&&!t.forceNode,this.protocols=t.protocols,this.usingBrowserWebSocket||(u=i),s.call(this,t)}var o,i,s=r(17),a=r(18),c=r(26),p=r(27),h=r(28);r(3)("engine.io-client:websocket");if("undefined"!=typeof WebSocket?o=WebSocket:"undefined"!=typeof self&&(o=self.WebSocket||self.MozWebSocket),"undefined"==typeof window)try{i=r(31)}catch(t){}var u=o||i;t.exports=n,p(n,s),n.prototype.name="websocket",n.prototype.supportsBinary=!0,n.prototype.doOpen=function(){if(this.check()){var t=this.uri(),e=this.protocols,r={agent:this.agent,perMessageDeflate:this.perMessageDeflate};r.pfx=this.pfx,r.key=this.key,r.passphrase=this.passphrase,r.cert=this.cert,r.ca=this.ca,r.ciphers=this.ciphers,r.rejectUnauthorized=this.rejectUnauthorized,this.extraHeaders&&(r.headers=this.extraHeaders),this.localAddress&&(r.localAddress=this.localAddress);try{this.ws=this.usingBrowserWebSocket&&!this.isReactNative?e?new u(t,e):new u(t):new u(t,e,r)}catch(t){return this.emit("error",t)}void 0===this.ws.binaryType&&(this.supportsBinary=!1),this.ws.supports&&this.ws.supports.binary?(this.supportsBinary=!0,this.ws.binaryType="nodebuffer"):this.ws.binaryType="arraybuffer",this.addEventListeners()}},n.prototype.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.onOpen()},this.ws.onclose=function(){t.onClose()},this.ws.onmessage=function(e){t.onData(e.data)},this.ws.onerror=function(e){t.onError("websocket error",e)}},n.prototype.write=function(t){function e(){r.emit("flush"),setTimeout(function(){r.writable=!0,r.emit("drain")},0)}var r=this;this.writable=!1;for(var n=t.length,o=0,i=n;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}t.exports=r,r.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),r=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-r:t+r}return 0|Math.min(t,this.max)},r.prototype.reset=function(){this.attempts=0},r.prototype.setMin=function(t){this.ms=t},r.prototype.setMax=function(t){this.max=t},r.prototype.setJitter=function(t){this.jitter=t}}])}); +//# sourceMappingURL=socket.io.slim.js.map \ No newline at end of file diff --git a/bot/modules/webserver/static/style.css b/bot/modules/webserver/static/style.css new file mode 100644 index 0000000..b84c06b --- /dev/null +++ b/bot/modules/webserver/static/style.css @@ -0,0 +1,19 @@ +table { + caption-side: bottom; +} + +table caption { + text-align: right; +} + +footer { + text-align: right; +} + +.right { + text-align: right; +} + +.center { + text-align: center; +} diff --git a/bot/modules/webserver/static/system.js b/bot/modules/webserver/static/system.js new file mode 100644 index 0000000..7b30f4b --- /dev/null +++ b/bot/modules/webserver/static/system.js @@ -0,0 +1,528 @@ +document.addEventListener("DOMContentLoaded", function(event) { + + // based on https://stackoverflow.com/a/56279295/8967590 + Audio.prototype.play = (function(play) { + return function () { + let audio = this; + let promise = play.apply(audio, arguments); + if (promise !== undefined) { + promise.catch(_ => { + console.log("autoplay of audiofile failed :("); + }); + } + }; + }) (Audio.prototype.play); + + let audio_files = []; + + function load_audio_files() { + audio_files["computer_work_beep"] = new Audio('/static/lcars/audio/computer_work_beep.mp3'); + audio_files["computer_error"] = new Audio('/static/lcars/audio/computer_error.mp3'); + audio_files["keyok1"] = new Audio('/static/lcars/audio/keyok1.mp3'); + audio_files["keyok1"].volume = 0.05; + audio_files["input_ok_2_clean"] = new Audio('/static/lcars/audio/input_ok_2_clean.mp3'); + audio_files["processing"] = new Audio('/static/lcars/audio/processing.mp3'); + audio_files["processing"].volume = 0.25; + audio_files["computerbeep_11"] = new Audio('/static/lcars/audio/computerbeep_11.mp3'); + audio_files["computerbeep_11"].volume = 0.5; + audio_files["computerbeep_38"] = new Audio('/static/lcars/audio/computerbeep_38.mp3'); + audio_files["computerbeep_38"].volume = 0.1; + audio_files["computerbeep_65"] = new Audio('/static/lcars/audio/computerbeep_65.mp3'); + audio_files["alarm01"] = new Audio('/static/lcars/audio/alarm01.mp3'); + audio_files["alarm03"] = new Audio('/static/lcars/audio/alarm03.mp3'); + audio_files["alert12"] = new Audio('/static/lcars/audio/alert12.mp3'); + } + + function play_audio_file(identifier) { + try { + if (audio_files[identifier].readyState === 4) { // 4 = HAVE_ENOUGH_DATA + if (!audio_files[identifier].ended) { + audio_files[identifier].currentTime = 0; + audio_files[identifier].play(); + } else { + audio_files[identifier].play(); + } + } + } catch(err) { + console.error("[AUDIO] Failed to play audio file:", identifier, err); + } + } + + /* found on https://stackoverflow.com/questions/14267781/sorting-html-table-with-javascript/49041392#49041392 + * slightly modified + */ + let index; // cell index + let toggleBool; // sorting asc, desc + window.sorting = function sorting(th, tbody, index) { + function compareCells(a, b) { + let aVal = a.cells[index].innerText.replace(/,/g, ''); + let bVal = b.cells[index].innerText.replace(/,/g, ''); + + if (toggleBool) { + let temp = aVal; + aVal = bVal; + bVal = temp; + } + + if (aVal.match(/^[0-9]+$/) && bVal.match(/^[0-9]+$/)) { + return parseFloat(aVal) - parseFloat(bVal); + } else { + if (aVal < bVal) { + return -1; + } else if (aVal > bVal) { + return 1; + } else { + return 0; + } + } + } + + this.index = index; + toggleBool = !toggleBool; + + let datas = []; + for (let i = 0; i < tbody.rows.length; i++) { + datas[i] = tbody.rows[i]; + } + + // sort by cell[index] + datas.sort(compareCells); + for (let i = 0; i < tbody.rows.length; i++) { + // rearrange table rows by sorted rows + tbody.appendChild(datas[i]); + } + }; + + /* found on https://stackoverflow.com/a/21648508/8967590 + * slightly modified to only return the rgb value and getting rid of type-warnings + */ + function hexToRgb(hex){ + let char; + if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) { + char = hex.substring(1).split(''); + if (char.length === 3) { + char = [char[0], char[0], char[1], char[1], char[2], char[2]]; + } + char = '0x' + char.join(''); + return [(char >> 16) & 255, (char >> 8) & 255, char & 255].join(', '); + } else { + alert(hex); + throw new Error('Bad Hex'); + } + } + + let lcars_colors = []; + function load_lcars_colors() { + /* https://davidwalsh.name/css-variables-javascript */ + lcars_colors["lcars-pale-canary"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-pale-canary').trim() + ); + lcars_colors["lcars-tanoi"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-tanoi').trim() + ); + lcars_colors["lcars-golden-tanoi"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-golden-tanoi').trim() + ); + lcars_colors["lcars-neon-carrot"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-neon-carrot').trim() + ); + + lcars_colors["lcars-eggplant"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-eggplant').trim() + ); + lcars_colors["lcars-lilac"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-lilac').trim() + ); + lcars_colors["lcars-anakiwa"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-anakiwa').trim() + ); + lcars_colors["lcars-mariner"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-mariner').trim() + ); + + lcars_colors["lcars-bahama-blue"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-bahama-blue').trim() + ); + lcars_colors["lcars-blue-bell"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-blue-bell').trim() + ); + lcars_colors["lcars-melrose"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-melrose').trim() + ); + lcars_colors["lcars-hopbush"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-hopbush').trim() + ); + + lcars_colors["lcars-chestnut-rose"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-chestnut-rose').trim() + ); + lcars_colors["lcars-orange-peel"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-orange-peel').trim() + ); + lcars_colors["lcars-atomic-tangerine"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-atomic-tangerine').trim() + ); + lcars_colors["lcars-danub"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-danub').trim() + ); + + lcars_colors["lcars-indigo"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-indigo').trim() + ); + lcars_colors["lcars-lavender-purple"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-lavender-purple').trim() + ); + lcars_colors["lcars-cosmic"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-cosmic').trim() + ); + lcars_colors["lcars-red-damask"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-red-damask').trim() + ); + + lcars_colors["lcars-medium-carmine"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-medium-carmine').trim() + ); + lcars_colors["lcars-bourbon"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-bourbon').trim() + ); + lcars_colors["lcars-sandy-brown"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-sandy-brown').trim() + ); + lcars_colors["lcars-periwinkle"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-periwinkle').trim() + ); + + lcars_colors["lcars-dodger-pale"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-dodger-pale').trim() + ); + lcars_colors["lcars-dodger-soft"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-dodger-soft').trim() + ); + lcars_colors["lcars-near-blue"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-near-blue').trim() + ); + lcars_colors["lcars-navy-blue"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-navy-blue').trim() + ); + + lcars_colors["lcars-husk"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-husk').trim() + ); + lcars_colors["lcars-rust"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-rust').trim() + ); + lcars_colors["lcars-tamarillo"] = hexToRgb( + getComputedStyle(document.documentElement).getPropertyValue('--lcars-tamarillo').trim() + ); + } + + // https://stackoverflow.com/a/38311629/8967590 + $.fn.setClass = function(classes) { + this.attr('class', classes); + return this; + }; + + // https://stackoverflow.com/a/46308265, + // slightly modified for better readability + $.fn.selectText = function(){ + let element = this[0], range, selection; + if (document.body.createTextRange) { + range = document.body.createTextRange(); + range.moveToElementText(element); + range.select(); + document.execCommand('copy'); + } else if (window.getSelection) { + selection = window.getSelection(); + range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + document.execCommand('copy'); + } + }; + + $.fn.upsert = function(target_element_id, htmlString) { + // upsert - find or create new element + let $el = $(this).find(target_element_id); + if ($el.length === 0) { + // didn't exist, create and add to caller + $el = $(htmlString); + $(this).prepend($el); + } + return $el; + }; + + let flash = function(elements, color=false) { + let opacity = 40; + if (color === false) { + color = lcars_colors["lcars-tanoi"]; // has to be in this format since we use rgba + } + let interval = setInterval(function() { + opacity -= 2.5; + if (opacity <= 0) { + clearInterval(interval); + $(elements).removeAttr('style'); + } else { + $(elements).css({ + "background-color": "rgba(" + color + ", " + (opacity / 50) + ")" + }); + } + }, 20) + }; + + //connect to the socket server. + window.socket = io.connect( + 'http://' + document.domain + ':' + location.port, { + 'sync disconnect on unload': true + } + ); + + window.socket.on('connected', function() { + window.socket.emit('ding'); + }); + + let start_time = (new Date).getTime(); + const PING_TIMEOUT_THRESHOLD = 5000; // Only log if ping takes >5 seconds + + window.setInterval(function() { + start_time = (new Date).getTime(); + socket.emit('ding'); + play_audio_file("processing"); + // No log for normal ping - would be spam (every 10 seconds) + }, 10000); + + window.socket.on('dong', function() { + let latency = (new Date).getTime() - start_time; + play_audio_file("keyok1"); + + // Only log slow pings + if (latency > PING_TIMEOUT_THRESHOLD) { + console.warn("[PING] Slow response: " + latency + "ms (threshold: " + PING_TIMEOUT_THRESHOLD + "ms)"); + } + }); + + // Session conflict handling + window.socket.on('session_conflict', function(data) { + console.log('[SESSION] Conflict detected:', data); + play_audio_file("alert12"); + + let message = data.message + '\n\nAktive Sessions: ' + data.existing_sessions; + if (confirm(message)) { + // User wants to take over + console.log('[SESSION] Taking over existing session'); + window.socket.emit('session_takeover_accept'); + } else { + // User declined + console.log('[SESSION] Takeover declined'); + window.socket.emit('session_takeover_decline'); + } + }); + + window.socket.on('session_accepted', function() { + console.log('[SESSION] Session accepted'); + play_audio_file("computerbeep_11"); + }); + + window.socket.on('session_declined', function(data) { + console.log('[SESSION] Session declined:', data.message); + play_audio_file("computer_error"); + alert(data.message); + // Browser will be disconnected by server + }); + + window.socket.on('session_taken_over', function(data) { + console.log('[SESSION] Session taken over by another browser'); + play_audio_file("alarm01"); + alert(data.message); + // Connection will be closed by server + // Show a visual indicator that session is no longer active + document.body.style.opacity = '0.5'; + document.body.style.pointerEvents = 'none'; + }); + + load_audio_files(); + load_lcars_colors(); + + window.socket.on('data', function(data) { + try { + // Log event for debugging (can be disabled in production) + if (window.socketDebugMode) { + console.log('[SOCKET] Received event:', data.data_type, data); + } + + if ([ + "element_content", + "widget_content", + "modal_content", + "remove_table_row", + "table_row", + "table_row_content" + ].includes(data["data_type"])) { + /* target element needs to be present for these operations */ + + // Validate data structure before accessing + if (!data["target_element"]) { + console.error('[SOCKET] Missing target_element in data:', data); + return false; + } + + let target_element_id = data["target_element"]["id"]; + if (target_element_id == null) { + console.warn('[SOCKET] target_element.id is null for data_type:', data["data_type"]); + return false; + } + + if (data["data_type"] === "widget_content") { + /* widget content requires a selector, in case the widget is not yet rendered in the browser + * with the help of the selector, we can create it in the right place + */ + let html_string = '
'; + let selector = data["target_element"]["selector"]; + let target_element = $(selector).upsert( + '#' + target_element_id, + html_string + ); + + if (data["method"] === "update") { + target_element.html(data["payload"]); + } else if (data["method"] === "append") { + target_element.append(data["payload"]); + } else if (data["method"] === "prepend") { + play_audio_file("computerbeep_38"); + let target_table = $('#' + target_element_id + ' ' + data["target_element"]["type"]); + /* prepend adds a row on top */ + target_table.prepend(data["payload"]); + let $entries = target_table.find('tr'); + if ($entries.length >= 50) { + $entries.last().remove(); + } + } + } + if (data["data_type"] === "element_content") { + let target_element = document.getElementById(target_element_id); + if (target_element == null) { + return false; + } + if (data["method"] === "update") { + if (target_element.innerHTML !== data["payload"]) { + target_element.innerHTML = data["payload"]; + } else { + return false; + } + } else if (data["method"] === "replace") { + // Note: After outerHTML replacement, target_element reference becomes invalid + // Flash BEFORE replacing, or flash the parent element + let parent = target_element.parentElement; + target_element.outerHTML = data["payload"]; + if (parent) { + // Flash the new element by finding it in the parent + let new_element = document.getElementById(target_element_id); + if (new_element) { + flash(new_element); + } + } + } + } + if (data["data_type"] === "modal_content") { + let target_element = document.getElementById(target_element_id); + if (target_element == null) { + return false; + } + + let modal_container = target_element.parentElement; + modal_container.classList.toggle("open"); + + $(target_element).html(data["payload"]) + } + if (data["data_type"] === "table_row") { + /* the whole row will be swapped out, not very economic ^^ + * can be suitable for smaller widgets, not needing the hassle of sub-element id's and stuff + * table_row content requires a selector, in case the row is not yet rendered in the browser + * with the help of the selector, we can create it in the right place + */ + play_audio_file("processing"); + let parent_element = $(data["target_element"]["selector"]); + + let target_element = parent_element.find("#" + target_element_id); + + if (target_element.length === 0) { + /* If the row doesn't exist, append it */ + parent_element.append(data["payload"]); + } else { + target_element.replaceWith(data["payload"]); + } + } + if (data["data_type"] === "table_row_content") { + play_audio_file("keyok1"); + let parent_element = $('#' + target_element_id); + if (parent_element.length === 0) { + return false; + } + if (data["target_element"]["class"].length >= 1) { + parent_element.setClass(data["target_element"]["class"]); + } else { + parent_element[0].removeAttribute("class"); + } + + let elements_to_update = data["payload"]; + $.each(elements_to_update, function (key, value) { + if ($.type(value) === 'object') { + $.each(value, function (sub_key, sub_value) { + let element_to_update = $('#' + target_element_id + '_' + key + '_' + sub_key); + if (element_to_update.length !== 0 && element_to_update.text() !== sub_value.toString()) { + element_to_update.html(sub_value); + } + }); + } else { + let element_to_update = $('#' + target_element_id + '_' + key); + if (element_to_update.length !== 0 && element_to_update.text() !== value.toString()) { + element_to_update.html(value); + } + } + }); + } + if (data["data_type"] === "remove_table_row") { + let target_element = document.getElementById(target_element_id); + if (target_element && target_element.parentElement) { + target_element.parentElement.removeChild(target_element); + } else { + console.warn('[SOCKET] Cannot remove table row - element not found:', target_element_id); + } + } + } else if (data["data_type"] === "status_message") { + /* this does not require any website containers. we simply play sounds and echo logs */ + if (data['status']) { + let json = data["status"]; + if (json["status"]) { + let status = json["status"]; + let action = data["payload"][0]; + if (status === "success") { + play_audio_file("computerbeep_11"); + } else if (status === "fail") { + play_audio_file("computer_error"); + flash(document.body, lcars_colors["lcars-chestnut-rose"]) + } + console.log( + "received status\n\"" + status + ":" + json["uuid4"] + "\"\n" + + "for action\n\"" + action + "\"" + ); + } + } + } + } catch (error) { + // Catch any errors to prevent handler from breaking + console.error('[SOCKET ERROR] Failed to process event:', { + error: error.message, + stack: error.stack, + data_type: data ? data.data_type : 'unknown', + data: data + }); + + // Play error sound to alert user + play_audio_file("computer_error"); + + // Flash screen red to indicate error + flash(document.body, lcars_colors["lcars-chestnut-rose"]); + } + }); +}); diff --git a/bot/modules/webserver/templates/frontpage/footer.html b/bot/modules/webserver/templates/frontpage/footer.html new file mode 100644 index 0000000..fff5678 --- /dev/null +++ b/bot/modules/webserver/templates/frontpage/footer.html @@ -0,0 +1 @@ +

© this page was created in 2019 by ecv for the chrani-bot webinterface

\ No newline at end of file diff --git a/bot/modules/webserver/templates/frontpage/header.html b/bot/modules/webserver/templates/frontpage/header.html new file mode 100644 index 0000000..4169bcc --- /dev/null +++ b/bot/modules/webserver/templates/frontpage/header.html @@ -0,0 +1,14 @@ +
+
+
+{% if current_user.is_authenticated %} + log out +{%- else %} + log in +{%- endif %} +
+
+
+
+

{{ title }}

+
diff --git a/bot/modules/webserver/templates/frontpage/index.html b/bot/modules/webserver/templates/frontpage/index.html new file mode 100644 index 0000000..c2fc4e5 --- /dev/null +++ b/bot/modules/webserver/templates/frontpage/index.html @@ -0,0 +1,36 @@ +{%- set not_configured_message = "You should not see this on a configured bot" -%} + + + + + + + + + {{ title }} + + {% if current_user.is_authenticated == True %} + + + {% endif %} + {%- if head -%}{{ head }}{%- endif -%} + + +
+
+ {{ header }} +
+
+
+
+ {{ main }} +
+
+
+
+ {{ footer }} +
+
+ + + diff --git a/bot/modules/webserver/templates/jinja2_macros.html b/bot/modules/webserver/templates/jinja2_macros.html new file mode 100644 index 0000000..3aea3eb --- /dev/null +++ b/bot/modules/webserver/templates/jinja2_macros.html @@ -0,0 +1,20 @@ +{%- macro construct_toggle_link(bool, active_text, deactivate_event, inactive_text, activate_event) -%} +{%- set bool = bool|default(false) -%} +{%- set active_text = active_text|default(none) -%} +{%- set deactivate_event = deactivate_event|default(none) -%} +{%- set inactive_text = inactive_text|default(none) -%} +{%- set activate_event = activate_event|default(none) -%} +{%- if bool == true -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ active_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- else -%} + {%- if deactivate_event != none and activate_event != none -%} + {{ inactive_text }} + {%- elif deactivate_event != none and activate_event == none -%} + {{ active_text }} + {%- endif -%} +{%- endif -%} +{%- endmacro -%} \ No newline at end of file diff --git a/bot/modules/webserver/templates/webserver_status_widget/component_logged_in_users.html b/bot/modules/webserver/templates/webserver_status_widget/component_logged_in_users.html new file mode 100644 index 0000000..32fe547 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/component_logged_in_users.html @@ -0,0 +1,9 @@ +{%- set logged_in_users_count = webserver_logged_in_users|length -%} + + + {{ logged_in_users_count }}{%- if logged_in_users_count == 1 %} user is {%- else %} users are {%- endif %} currently using the webinterface!
+ {%- if webserver_logged_in_users -%} + ({{ webserver_logged_in_users|join(', ') }}) + {%- endif -%} + + diff --git a/bot/modules/webserver/templates/webserver_status_widget/control_servertime.html b/bot/modules/webserver/templates/webserver_status_widget/control_servertime.html new file mode 100644 index 0000000..d649bd8 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/control_servertime.html @@ -0,0 +1,5 @@ +
+ +
{{ time }}
+
+
diff --git a/bot/modules/webserver/templates/webserver_status_widget/control_switch_options_view.html b/bot/modules/webserver/templates/webserver_status_widget/control_switch_options_view.html new file mode 100644 index 0000000..1135a13 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/control_switch_options_view.html @@ -0,0 +1,9 @@ +{%- from 'jinja2_macros.html' import construct_toggle_link with context -%} +
+ {{ construct_toggle_link( + options_view_toggle, + "options", ['widget_event', ['webserver', ['toggle_webserver_status_widget_view', {'steamid': steamid, "action": "show_options"}]]], + "back", ['widget_event', ['webserver', ['toggle_webserver_status_widget_view', {'steamid': steamid, "action": "show_frontend"}]]] + )}} +
+ diff --git a/bot/modules/webserver/templates/webserver_status_widget/control_switch_view.html b/bot/modules/webserver/templates/webserver_status_widget/control_switch_view.html new file mode 100644 index 0000000..2ef7b34 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/control_switch_view.html @@ -0,0 +1,4 @@ +
+ {{ control_switch_options_view }} + {{ control_servertime }} +
\ No newline at end of file diff --git a/bot/modules/webserver/templates/webserver_status_widget/view_frontend.html b/bot/modules/webserver/templates/webserver_status_widget/view_frontend.html new file mode 100644 index 0000000..459f4e3 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/view_frontend.html @@ -0,0 +1,28 @@ +
+
+ Webinterface +
+
+ +
+ + + + + + + + + {{ component_logged_in_users }} + +
+ consume +
Webserver Status
+
+ +
+
\ No newline at end of file diff --git a/bot/modules/webserver/templates/webserver_status_widget/view_options.html b/bot/modules/webserver/templates/webserver_status_widget/view_options.html new file mode 100644 index 0000000..676a8b4 --- /dev/null +++ b/bot/modules/webserver/templates/webserver_status_widget/view_options.html @@ -0,0 +1,27 @@ +
+
+ Webinterface +
+
+ +
+ + + + + + + + + + + {% for key, value in widget_options.items() %} + + + + {% endfor %} + +
webserver widget options
widget-options
{{key}}{{value}}
+
\ No newline at end of file diff --git a/bot/modules/webserver/user.py b/bot/modules/webserver/user.py new file mode 100644 index 0000000..5e292b8 --- /dev/null +++ b/bot/modules/webserver/user.py @@ -0,0 +1,30 @@ +from flask_login import UserMixin +from time import time + + +class User(UserMixin, object): + id = str + last_seen = float + browser_token = str + socket_ids = list # Multiple socket IDs for multiple browser sessions + + def __init__(self, steamid, last_seen=None): + self.id = steamid + self.last_seen = time() if last_seen is None else last_seen + self.instance_token = "anonymous" + self.socket_ids = [] # Track all socket connections for this user + + def add_socket(self, sid): + """Add a socket ID to this user's connections.""" + if sid not in self.socket_ids: + self.socket_ids.append(sid) + + def remove_socket(self, sid): + """Remove a socket ID from this user's connections.""" + if sid in self.socket_ids: + self.socket_ids.remove(sid) + + @property + def sid(self): + """Return the first (primary) socket ID for backward compatibility.""" + return self.socket_ids[0] if self.socket_ids else None diff --git a/bot/modules/webserver/widgets/webserver_status_widget.py b/bot/modules/webserver/widgets/webserver_status_widget.py new file mode 100644 index 0000000..7ededc2 --- /dev/null +++ b/bot/modules/webserver/widgets/webserver_status_widget.py @@ -0,0 +1,190 @@ +from bot import loaded_modules_dict +from os import path, pardir + +module_name = path.basename(path.normpath(path.join(path.abspath(__file__), pardir, pardir))) +widget_name = path.basename(path.abspath(__file__))[:-3] + + +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + + current_view = module.get_current_view(dispatchers_steamid) + if current_view == "options": + options_view(module, dispatchers_steamid=dispatchers_steamid) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) + + +def frontend_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + template_frontend = module.templates.get_template('webserver_status_widget/view_frontend.html') + template_servertime = module.templates.get_template('webserver_status_widget/control_servertime.html') + + template_options_toggle = module.templates.get_template('webserver_status_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template( + 'webserver_status_widget/control_switch_options_view.html' + ) + + component_logged_in_users = module.templates.get_template('webserver_status_widget/component_logged_in_users.html') + + try: + server_is_online = module.dom.data.get("module_telnet").get("server_is_online", True) + except AttributeError: + server_is_online = True + + current_view = module.get_current_view(dispatchers_steamid) + webserver_logged_in_users = ( + module.dom.data + .get(module.get_module_identifier(), {}) + .get("webserver_logged_in_users", []) + ) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + component_logged_in_users=module.template_render_hook( + module, + template=component_logged_in_users, + webserver_logged_in_users=webserver_logged_in_users + ), + options_toggle=module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + steamid=dispatchers_steamid, + options_view_toggle=(current_view == "frontend") + ), + control_servertime=module.template_render_hook( + module, + template=template_servertime, + time=module.dom.data.get("module_telnet", {}).get("last_recorded_servertime", None), + ) + ), + server_is_online=server_is_online + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "webserver_status_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def options_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get("dispatchers_steamid", None) + + template_frontend = module.templates.get_template('webserver_status_widget/view_options.html') + template_servertime = module.templates.get_template('webserver_status_widget/control_servertime.html') + + template_options_toggle = module.templates.get_template('webserver_status_widget/control_switch_view.html') + template_options_toggle_view = module.templates.get_template( + 'webserver_status_widget/control_switch_options_view.html' + ) + + current_view = module.get_current_view(dispatchers_steamid) + + data_to_emit = module.template_render_hook( + module, + template=template_frontend, + options_toggle=module.template_render_hook( + module, + template=template_options_toggle, + control_switch_options_view=module.template_render_hook( + module, + template=template_options_toggle_view, + steamid=dispatchers_steamid, + options_view_toggle=(current_view == "frontend") + ), + control_servertime=module.template_render_hook( + module, + template=template_servertime, + time=module.dom.data.get("module_telnet").get("last_recorded_servertime", None), + ) + ), + widget_options=module.webserver.options + ) + + module.webserver.send_data_to_client_hook( + module, + payload=data_to_emit, + data_type="widget_content", + clients=[dispatchers_steamid], + target_element={ + "id": "webserver_status_widget", + "type": "table", + "selector": "body > main > div" + } + ) + + +def update_servertime(*args, **kwargs): + module = args[0] + + template_servertime = module.templates.get_template('webserver_status_widget/control_servertime.html') + servertime_view = module.template_render_hook( + module, + template=template_servertime, + time=module.dom.data.get("module_telnet").get("last_recorded_servertime", None) + ) + + module.webserver.send_data_to_client_hook( + module, + payload=servertime_view, + data_type="element_content", + method="replace", + clients=module.webserver.connected_clients.keys(), + target_element={ + "id": "server_status_widget_servertime" + } + ) + + +def update_logged_in_users(*args, **kwargs): + module = args[0] + updated_values_dict = kwargs.get("updated_values_dict", None) + + webserver_logged_in_users = updated_values_dict.get("webserver_logged_in_users", []) + + component_logged_in_users = module.templates.get_template('webserver_status_widget/component_logged_in_users.html') + component_logged_in_users_view = module.template_render_hook( + module, + template=component_logged_in_users, + webserver_logged_in_users=webserver_logged_in_users + ) + + module.webserver.send_data_to_client_hook( + module, + payload=component_logged_in_users_view, + data_type="element_content", + method="replace", + clients=module.webserver.connected_clients.keys(), + target_element={ + "id": "server_status_widget_logged_in_users" + } + ) + + +widget_meta = { + "description": "shows all users with an active session for the webinterface and some other stats", + "main_widget": select_view, + "handlers": { + "module_webserver/visibility/%steamid%/current_view": select_view, + "module_webserver/webserver_logged_in_users": update_logged_in_users, + "module_telnet/last_recorded_servertime": update_servertime + }, + "enabled": True +} + +loaded_modules_dict["module_" + module_name].register_widget(widget_name, widget_meta) diff --git a/bot/options/README.md b/bot/options/README.md new file mode 100644 index 0000000..964008c --- /dev/null +++ b/bot/options/README.md @@ -0,0 +1,42 @@ +# default options +Store default configurations for your modules + +## naming scheme +Name the config file exactly the same as the module_identifier / the directory-name of the module with a prepended +"module_" and an appended ".json". +For example: The default options file for the module "webserver" would become "module_webserver.json" + +## priorities +If no config file or database is found, the hardcoded ones from the module will be used +Options-file settings will override hardcoded module settings +Database settings will override the options-file settings + +## minimum settings for 7dtd: +### setting up the telnet component +host would be your IP address, or if the bot is on the same machine, localhost +port + password is what you have set up in your 7dtd serverconfig + + chrani-bot-tng/bot/options/module_telnet.json +> { +> "host": "127.0.0.1", +> "port": 26902, +> "password": "supersecret" +> } + +You might want to set the telnet buffer to a higher value if you have a high-pop server or are using mods like +Darkness Falls. the standard 16k is too small for extensive amount of entities +> "max_telnet_buffer": 32768 + +### setting up the webserver component +host would be your servers public IP address +port and secret-key can be whatever you feel is clever + + chrani-bot-tng/bot/options/module_webserver.json +> { +> "host": "YOUR PUBLIC SERVER IP", +> "port": 26905, +> "Flask_secret_key": "whateverisclever" +> } + +With those two files in place and some sensible data to populate them, the bot should start up and provide it's +webinterface and should start listening to the games telnet. diff --git a/bot/resources/GUIDELINES_DEPLOYMENT.md b/bot/resources/GUIDELINES_DEPLOYMENT.md new file mode 100644 index 0000000..406636a --- /dev/null +++ b/bot/resources/GUIDELINES_DEPLOYMENT.md @@ -0,0 +1,673 @@ +# chrani-bot-tng Deployment-Anleitung + +Umfassende Anleitung zur Installation, Konfiguration und zum Betrieb von chrani-bot-tng mit modernem Python, gunicorn und nginx. + +## Inhaltsverzeichnis + +1. [Systemanforderungen](#systemanforderungen) +2. [Schnellstart - Lokales Testen](#schnellstart---lokales-testen) +3. [Produktions-Deployment mit gunicorn](#produktions-deployment-mit-gunicorn) +4. [Deployment mit nginx (Reverse Proxy)](#deployment-mit-nginx-reverse-proxy) +5. [Systemd-Service einrichten](#systemd-service-einrichten) +6. [Fehlerbehebung](#fehlerbehebung) +7. [Wartung und Updates](#wartung-und-updates) + +--- + +## Systemanforderungen + +### Unterstützte Systeme +- **Linux**: Ubuntu 20.04+, Debian 10+, CentOS 8+, Fedora, Arch Linux +- **Python**: 3.8 oder höher (empfohlen: Python 3.10+) +- **Weitere Software**: + - Git + - pip (Python Package Manager) + - virtualenv oder venv + - Optional: nginx (für Reverse Proxy) + - Optional: systemd (für Service-Management) + +### Mindestanforderungen Hardware +- **CPU**: 1 Core +- **RAM**: 512 MB (1 GB empfohlen) +- **Festplatte**: 500 MB freier Speicher + +--- + +## Schnellstart - Lokales Testen + +Diese Anleitung zeigt Ihnen, wie Sie chrani-bot-tng lokal auf Ihrem Computer zum Testen ausführen können. + +### Schritt 1: Repository klonen + +```bash +cd ~ +git clone https://github.com/your-username/chrani-bot-tng.git +cd chrani-bot-tng +``` + +### Schritt 2: Python Virtual Environment erstellen + +Ein Virtual Environment isoliert die Python-Abhängigkeiten des Projekts von Ihrem System. + +```bash +# Virtual Environment erstellen +python3 -m venv venv + +# Virtual Environment aktivieren +source venv/bin/activate + +# Ihr Prompt sollte jetzt mit (venv) beginnen +``` + +### Schritt 3: Abhängigkeiten installieren + +```bash +# pip aktualisieren +pip install --upgrade pip + +# Projektabhängigkeiten installieren +pip install -r requirements.txt +``` + +### Schritt 4: Konfiguration anpassen + +Die Bot-Konfiguration befindet sich in JSON-Dateien im `bot/options/` Verzeichnis. + +```bash +# Verzeichnis prüfen +ls -la bot/options/ + +# Beispiel: Webserver-Konfiguration bearbeiten +nano bot/options/module_webserver.json +``` + +**Wichtige Einstellungen** in `module_webserver.json`: +```json +{ + "host": "0.0.0.0", + "port": 5000, + "Flask_secret_key": "ÄNDERN-SIE-DIES-IN-PRODUKTION" +} +``` + +**Telnet-Konfiguration** (für Verbindung zum 7 Days to Die Server): +```bash +nano bot/options/module_telnet.json +``` + +Passen Sie die Telnet-Verbindungsdetails an Ihren Gameserver an. + +### Schritt 5: Bot starten (Entwicklungsmodus) + +Es gibt zwei Möglichkeiten, den Bot zu starten: + +#### Methode A: Standalone-Modus (Flask Development Server) + +```bash +# Mit dem originalen Entry-Point +python3 app.py +``` + +Der Bot sollte starten und ausgeben: +``` +modules started: ['module_dom', 'module_storage', 'module_telnet', ...] +``` + +Der Webserver läuft auf: `http://localhost:5000` + +#### Methode B: Mit gunicorn (empfohlen für Tests) + +```bash +# Mit gunicorn starten (wie in Produktion) +gunicorn -c gunicorn.conf.py wsgi:application +``` + +Vorteile: +- Näher an der Produktionsumgebung +- Bessere Performance +- WebSocket-Support optimiert + +### Schritt 6: Im Browser testen + +Öffnen Sie Ihren Browser und navigieren Sie zu: + +``` +http://localhost:5000 +``` + +Sie sollten die LCARS-Style Weboberfläche sehen. Klicken Sie auf "use your steam-account to log in" um sich zu authentifizieren. + +### Schritt 7: Bot beenden + +```bash +# STRG+C drücken um den Bot zu stoppen + +# Virtual Environment deaktivieren +deactivate +``` + +--- + +## Produktions-Deployment mit gunicorn + +Für den Produktionsbetrieb ist gunicorn deutlich besser geeignet als der Flask Development Server. + +### Warum gunicorn? + +- **Performance**: Optimiert für hohe Last und viele gleichzeitige Verbindungen +- **Stabilität**: Automatisches Neustart bei Fehlern +- **WebSocket-Support**: Mit gevent-websocket-Worker für Socket.IO optimiert +- **Production-Ready**: Bewährt in vielen großen Projekten + +### Schritt 1: Konfiguration überprüfen + +Die Datei `gunicorn.conf.py` enthält die gunicorn-Konfiguration: + +```bash +nano gunicorn.conf.py +``` + +**Wichtige Einstellungen**: + +```python +# Server Socket - wo gunicorn lauscht +bind = "0.0.0.0:5000" # Alle Interfaces, Port 5000 + +# Worker - WICHTIG: Für WebSocket nur 1 Worker! +workers = 1 +worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" # für WebSocket-Upgrades +worker_connections = 1000 + +# Timeouts +timeout = 120 +keepalive = 5 +``` + +Hinweis: In `wsgi.py` exportiert `wsgi:application` die Flask-App (`webserver_module.app`), WebSockets laufen über den gevent-websocket-Worker. + +### Schritt 2: Bot mit gunicorn starten + +```bash +# Virtual Environment aktivieren +cd ~/chrani-bot-tng +source venv/bin/activate + +# Mit gunicorn starten +gunicorn -c gunicorn.conf.py wsgi:application +``` + +Sie sollten folgende Ausgabe sehen: + +``` +============================================================ +chrani-bot-tng is starting... +============================================================ +Worker spawned (pid: 12345) +Worker initialized (pid: 12345) +webserver: Running under WSGI server mode +modules started: [...] +============================================================ +chrani-bot-tng is ready to accept connections +Listening on: 0.0.0.0:5000 +============================================================ +``` + +### Schritt 3: Verbindung testen + +```bash +# In einem neuen Terminal: +curl http://localhost:5000 + +# Oder im Browser: +# http://your-server-ip:5000 +``` + +### Schritt 4: Bot im Hintergrund laufen lassen + +```bash +# Mit nohup (einfache Methode) +nohup gunicorn -c gunicorn.conf.py wsgi:application > gunicorn.log 2>&1 & + +# Prozess-ID wird angezeigt +echo $! > gunicorn.pid + +# Logs anschauen +tail -f gunicorn.log + +# Bot stoppen +kill $(cat gunicorn.pid) +``` + +**Besser**: Verwenden Sie systemd (siehe Abschnitt unten) + +--- + +## Deployment mit nginx (Reverse Proxy) + +nginx als Reverse Proxy bietet zusätzliche Vorteile: + +- **SSL/TLS-Terminierung** (HTTPS-Verschlüsselung) +- **Load Balancing** (bei mehreren Instanzen) +- **Static File Serving** (schnelleres Ausliefern von CSS/JS) +- **DDoS-Schutz** und Rate Limiting +- **Besseres Logging** + +### Schritt 1: nginx installieren + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install nginx + +# CentOS/RHEL +sudo yum install nginx + +# Fedora +sudo dnf install nginx +``` + +### Schritt 2: nginx-Konfiguration erstellen + +```bash +# Beispielkonfiguration kopieren +sudo cp nginx.conf.example /etc/nginx/sites-available/chrani-bot-tng + +# Konfiguration bearbeiten +sudo nano /etc/nginx/sites-available/chrani-bot-tng +``` + +**Wichtige Anpassungen**: + +1. **Server-Name ändern**: +```nginx +server_name your-domain.com www.your-domain.com; +``` + +2. **SSL-Zertifikate** (für HTTPS): +```nginx +ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; +ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; +``` + +3. **Upstream-Server prüfen**: +```nginx +upstream chrani_bot_app { + server 127.0.0.1:5000 fail_timeout=0; +} +``` + +### Schritt 3: Konfiguration aktivieren + +```bash +# Symlink erstellen +sudo ln -s /etc/nginx/sites-available/chrani-bot-tng /etc/nginx/sites-enabled/ + +# Standard-Site deaktivieren (optional) +sudo rm /etc/nginx/sites-enabled/default + +# Konfiguration testen +sudo nginx -t + +# Bei erfolgreicher Prüfung: +sudo systemctl reload nginx +``` + +### Schritt 4: Firewall konfigurieren + +```bash +# ufw (Ubuntu/Debian) +sudo ufw allow 'Nginx Full' +sudo ufw enable + +# firewalld (CentOS/RHEL/Fedora) +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload +``` + +### Schritt 5: SSL-Zertifikat mit Let's Encrypt (optional) + +```bash +# Certbot installieren +sudo apt install certbot python3-certbot-nginx # Ubuntu/Debian +# sudo yum install certbot python3-certbot-nginx # CentOS + +# Zertifikat erstellen (interaktiv) +sudo certbot --nginx -d your-domain.com -d www.your-domain.com + +# Automatische Erneuerung testen +sudo certbot renew --dry-run +``` + +### Schritt 6: Zugriff testen + +```bash +# HTTP (sollte zu HTTPS umleiten) +curl -I http://your-domain.com + +# HTTPS +curl -I https://your-domain.com +``` + +Im Browser: `https://your-domain.com` + +--- + +## Systemd-Service einrichten + +Mit systemd läuft chrani-bot-tng als System-Service, startet automatisch beim Booten und wird bei Fehlern neu gestartet. + +### Schritt 1: Service-Datei anpassen + +```bash +# Service-Datei bearbeiten +nano chrani-bot-tng.service +``` + +**Wichtige Anpassungen**: + +```ini +# Benutzer und Gruppe ändern +User=your-username +Group=your-username + +# Pfade anpassen +WorkingDirectory=/home/your-username/chrani-bot-tng +Environment="PATH=/home/your-username/chrani-bot-tng/venv/bin" +ExecStart=/home/your-username/chrani-bot-tng/venv/bin/gunicorn -c gunicorn.conf.py wsgi:application +``` + +### Schritt 2: Service installieren + +```bash +# Service-Datei nach systemd kopieren +sudo cp chrani-bot-tng.service /etc/systemd/system/ + +# systemd neu laden +sudo systemctl daemon-reload + +# Service aktivieren (Autostart beim Booten) +sudo systemctl enable chrani-bot-tng + +# Service starten +sudo systemctl start chrani-bot-tng +``` + +### Schritt 3: Service-Status prüfen + +```bash +# Status anzeigen +sudo systemctl status chrani-bot-tng + +# Logs anzeigen +sudo journalctl -u chrani-bot-tng -f + +# Logs der letzten Stunde +sudo journalctl -u chrani-bot-tng --since "1 hour ago" +``` + +### Schritt 4: Service-Befehle + +```bash +# Service stoppen +sudo systemctl stop chrani-bot-tng + +# Service neu starten +sudo systemctl restart chrani-bot-tng + +# Service neu laden (bei Konfigurationsänderungen) +sudo systemctl reload chrani-bot-tng + +# Autostart deaktivieren +sudo systemctl disable chrani-bot-tng +``` + +--- + +## Fehlerbehebung + +### Problem: "Module not found" Fehler + +**Symptom**: ImportError beim Starten + +**Lösung**: +```bash +# Virtual Environment aktiviert? +source venv/bin/activate + +# Dependencies neu installieren +pip install -r requirements.txt + +# Python-Version prüfen (mindestens 3.8) +python3 --version +``` + +### Problem: Port bereits in Benutzung + +**Symptom**: "Address already in use" + +**Lösung**: +```bash +# Prozess auf Port 5000 finden +sudo lsof -i :5000 + +# Oder mit netstat +sudo netstat -tulpn | grep :5000 + +# Prozess beenden +sudo kill -9 + +# Oder anderen Port in gunicorn.conf.py verwenden +``` + +### Problem: WebSocket-Verbindung schlägt fehl + +**Symptom**: "WebSocket connection failed" im Browser + +**Lösung**: +1. Prüfen Sie, dass nur 1 Worker in `gunicorn.conf.py` konfiguriert ist: + ```python + workers = 1 + worker_class = "gevent" + ``` + +2. nginx-Konfiguration prüfen (WebSocket-Headers): + ```nginx + location /socket.io/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + ``` + +### Problem: 502 Bad Gateway (nginx) + +**Symptom**: nginx zeigt "502 Bad Gateway" + +**Lösung**: +```bash +# Ist gunicorn gestartet? +sudo systemctl status chrani-bot-tng + +# Logs prüfen +sudo journalctl -u chrani-bot-tng -n 50 + +# nginx-Fehlerlog prüfen +sudo tail -f /var/log/nginx/chrani-bot-tng-error.log + +# Upstream in nginx.conf prüfen +# Stimmt IP und Port mit gunicorn überein? +``` + +### Problem: Hohe CPU-Last + +**Symptom**: Server reagiert langsam, CPU bei 100% + +**Lösung**: +```bash +# Prozesse prüfen +top -u your-username + +# gunicorn-Worker-Anzahl anpassen (aber max. 1 für WebSocket!) +# Timeout erhöhen in gunicorn.conf.py +timeout = 300 + +# Logs auf Endlosschleifen prüfen +sudo journalctl -u chrani-bot-tng -f +``` + +### Problem: Telnet-Verbindung zum Gameserver schlägt fehl + +**Symptom**: Bot kann sich nicht mit dem 7 Days to Die Server verbinden + +**Lösung**: +1. Telnet-Einstellungen prüfen: + ```bash + nano bot/options/module_telnet.json + ``` + +2. Verbindung manuell testen: + ```bash + telnet + # Passwort eingeben + ``` + +3. Firewall-Regeln prüfen +4. Gameserver-Konfiguration prüfen (telnet aktiviert?) + +--- + +## Wartung und Updates + +### Code-Updates einspielen + +```bash +# Zum Projektverzeichnis +cd ~/chrani-bot-tng + +# Änderungen abrufen +git fetch origin + +# Aktuellen Branch prüfen +git branch + +# Updates herunterladen und anwenden +git pull origin main # oder Ihr Branch-Name + +# Dependencies aktualisieren +source venv/bin/activate +pip install -r requirements.txt --upgrade + +# Service neu starten +sudo systemctl restart chrani-bot-tng +``` + +### Backup erstellen + +```bash +# Gesamtes Projektverzeichnis sichern +tar -czf chrani-bot-tng-backup-$(date +%Y%m%d).tar.gz ~/chrani-bot-tng + +# Nur Konfiguration sichern +tar -czf chrani-bot-config-backup-$(date +%Y%m%d).tar.gz ~/chrani-bot-tng/bot/options + +# Backup an sicheren Ort verschieben +mv chrani-bot-*.tar.gz /path/to/backup/location/ +``` + +### Logs rotieren + +nginx rotiert Logs automatisch. Für gunicorn/systemd: + +```bash +# Log-Größe prüfen +sudo journalctl --disk-usage + +# Alte Logs löschen (älter als 7 Tage) +sudo journalctl --vacuum-time=7d + +# Oder nach Größe (maximal 500 MB behalten) +sudo journalctl --vacuum-size=500M +``` + +### Performance-Monitoring + +```bash +# Systemressourcen überwachen +htop + +# Nur chrani-bot-tng Prozesse +htop -u your-username + +# Netzwerk-Verbindungen prüfen +sudo netstat -tulpn | grep gunicorn + +# Echtzeit-Logs +sudo journalctl -u chrani-bot-tng -f +``` + +--- + +## Zusammenfassung der Befehle + +### Lokaler Test (Schnellstart) + +```bash +# Setup +git clone +cd chrani-bot-tng +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Starten +gunicorn -c gunicorn.conf.py wsgi:application + +# Stoppen: STRG+C +``` + +### Produktions-Deployment + +```bash +# Einmalige Einrichtung +sudo cp chrani-bot-tng.service /etc/systemd/system/ +sudo cp nginx.conf.example /etc/nginx/sites-available/chrani-bot-tng +sudo ln -s /etc/nginx/sites-available/chrani-bot-tng /etc/nginx/sites-enabled/ +sudo systemctl daemon-reload +sudo systemctl enable chrani-bot-tng +sudo systemctl enable nginx + +# Starten +sudo systemctl start chrani-bot-tng +sudo systemctl start nginx + +# Status +sudo systemctl status chrani-bot-tng +sudo journalctl -u chrani-bot-tng -f +``` + +--- + +## Weitere Ressourcen + +- **Gunicorn Dokumentation**: https://docs.gunicorn.org/ +- **nginx Dokumentation**: https://nginx.org/en/docs/ +- **Flask-SocketIO Dokumentation**: https://flask-socketio.readthedocs.io/ +- **systemd Dokumentation**: https://www.freedesktop.org/software/systemd/man/ + +--- + +## Support + +Bei Problemen: + +1. Prüfen Sie die Logs: `sudo journalctl -u chrani-bot-tng -n 100` +2. Suchen Sie in den GitHub Issues +3. Erstellen Sie ein neues Issue mit: + - Fehlermeldung + - Logs + - Systeminfo (OS, Python-Version, etc.) + +--- + +**Viel Erfolg mit chrani-bot-tng!** 🚀 diff --git a/bot/resources/GUIDELINES_LOGGING.md b/bot/resources/GUIDELINES_LOGGING.md new file mode 100644 index 0000000..9fa6549 --- /dev/null +++ b/bot/resources/GUIDELINES_LOGGING.md @@ -0,0 +1,106 @@ +# Logging System Analysis & Plan + +## System Architecture Overview + +``` +Browser (JavaScript) + ↕ Socket.io +Python Backend (Flask + Socket.io) + ↕ Telnet +7D2D Game Server +``` + +## Current Logging State + +### Python Backend +- **Format:** Inconsistent mix of: + - `print(f"[PREFIX] message")` + - `print("{}: message".format(module))` + - Plain `print("message")` +- **No:** + - Timestamps + - Log levels (ERROR, WARN, INFO, DEBUG) + - User context + - Correlation IDs + - Structured data + +### JavaScript Frontend +- **Issues:** + - Ding/Dong logs every 10 seconds (spam) + - Many debug messages that don't help troubleshooting + - No structured logging + - Success messages mixed with errors + +## Event Flow Analysis + +### 1. User Action Flow +``` +User clicks checkbox (Browser) + → Widget event sent via Socket.io + → Python: webserver receives event + → Python: Action handler (e.g., dom_management/select) + → Python: DOM upsert + → Python: Callback triggers + → Python: Widget handler updates + → Socket.io sends update back + → Browser: DOM updated +``` + +**Critical Logging Points:** +- [ ] Action received (user, action, data) +- [ ] Action validation failed +- [ ] DOM operation (path, method, user) +- [ ] Callback trigger (which handler, why) +- [ ] Socket send (to whom, what type) + +### 2. Tile Request Flow +``` +Browser requests tile (HTTP GET) + → Python: webserver /map_tiles route + → Python: Auth check + → Python: Proxy to game server + → 7D2D: Returns tile OR error + → Python: Forward response OR error + → Browser: Displays tile OR 404 +``` + +**Critical Logging Points:** +- [ ] Tile request (only on ERROR) +- [ ] Auth failure +- [ ] Game server error (status code, url) +- [ ] Network timeout + +### 3. Telnet Command Flow +``` +Python: Action needs game data + → Python: Telnet send command + → 7D2D: Processes command + → 7D2D: Returns response + → Python: Parse response + → Python: Update DOM + → Python: Trigger callbacks +``` + +## Log Format +### Python Backend Format +``` +[LEVEL] [TIMESTAMP] event_name | context_key=value context_key=value +``` + +**Example:** +``` +[ERROR] [2025-01-19 12:34:56.123] tile_fetch_failed | user=steamid123 z=4 x=-2 y=1 status=404 url=http://... +[WARN ] [2025-01-19 12:34:57.456] auth_missing_sid | user=steamid456 action=tile_request +[INFO ] [2025-01-19 12:00:00.000] module_loaded | module=webserver version=1.0 +``` + +### JavaScript Frontend Format +``` +[PREFIX] event_name | context +``` + +**Example:** +``` +[SOCKET ERROR] event_processing_failed | data_type=widget_content error=Cannot read property 'id' of undefined +[MAP ERROR ] shape_creation_failed | location_id=map_owner_loc1 shape=circle error=Invalid radius +``` diff --git a/bot/resources/GUIDELINES_MODULE_WIDGET_MENU.md b/bot/resources/GUIDELINES_MODULE_WIDGET_MENU.md new file mode 100644 index 0000000..91a315d --- /dev/null +++ b/bot/resources/GUIDELINES_MODULE_WIDGET_MENU.md @@ -0,0 +1,211 @@ +### Add a menu button and view to a module widget (How‑To) + +This guide explains how to add a new button to a widget's pull‑out menu and display a corresponding screen ("view"). It follows the menu pattern used in the `locations` module and the example implemented in the `telnet` module. + +The pattern consists of four parts: +- A view registry in the widget Python file that defines available views and their labels. +- A shared Jinja2 macro `construct_view_menu` to render the menu. +- A small widget template `control_view_menu.html` that invokes the macro for the module. +- A server action `toggle__widget_view` that switches the current view. + +Below, `` stands for your module name (e.g., `telnet`, `locations`), and `` is the identifier of the view you are adding (e.g., `test`). + +--- + +#### 1) Ensure the view menu macro is available for your module + +Your module's Jinja2 macros file (e.g., `bot/modules//templates/jinja2_macros.html`) should contain the macro `construct_view_menu`. If your module doesn't have it yet, mirror the implementation from: +- `bot/modules/locations/templates/jinja2_macros.html` + +The macro expects these parameters: +- `views`: dict of view entries (see step 2) +- `current_view`: name of the active view +- `module_name`: the module identifier (e.g., `telnet`) +- `steamid`: the current user's steamid +- `default_view`: the view to return to from an active state (usually `frontend`) + +The macro renders toggle links that emit the standard socket event: +``` +['widget_event', [module_name, ['toggle_' ~ module_name ~ '_widget_view', {'steamid': steamid, 'action': action}]]] +``` + +--- + +#### 2) Define or extend the VIEW_REGISTRY in the widget Python file + +In your widget file (e.g., `bot/modules//widgets/.py`) define a `VIEW_REGISTRY` mapping of view IDs to config objects. Each entry can have: +- `label_active`: label when this view is currently active (typically `back`) +- `label_inactive`: label when the view is not active (e.g., `options`, `test`) +- `action`: the action string to be sent by the menu (e.g., `show_options`, `show_test`) +- `include_in_menu`: whether to show it in the menu (set `False` for base `frontend`) + +Example (excerpt with a new `test` view): +```python +VIEW_REGISTRY = { + 'frontend': { + 'label_active': 'back', + 'label_inactive': 'main', + 'action': 'show_frontend', + 'include_in_menu': False + }, + 'options': { + 'label_active': 'back', + 'label_inactive': 'options', + 'action': 'show_options', + 'include_in_menu': True + }, + 'test': { + 'label_active': 'back', + 'label_inactive': 'test', + 'action': 'show_test', + 'include_in_menu': True + } +} +``` + +Update `select_view` to route to your new view: +```python +def select_view(*args, **kwargs): + module = args[0] + dispatchers_steamid = kwargs.get('dispatchers_steamid', None) + current_view = module.get_current_view(dispatchers_steamid) + if current_view == 'options': + options_view(module, dispatchers_steamid=dispatchers_steamid) + elif current_view == 'test': + test_view(module, dispatchers_steamid=dispatchers_steamid) + else: + frontend_view(module, dispatchers_steamid=dispatchers_steamid) +``` + +--- + +#### 3) Create the control_view_menu.html template for the widget + +Create a template next to your widget's templates folder that renders the menu by importing the macro and passing the registry: + +`bot/modules//templates//control_view_menu.html` +```jinja2 +{%- from 'jinja2_macros.html' import construct_view_menu with context -%} +
+ {{ construct_view_menu( + views=views, + current_view=current_view, + module_name='', + steamid=steamid, + default_view='frontend' + )}} +
+``` + +Replace `` with the widget DOM id (e.g., `telnet_table_widget_options_toggle`), and `` with the module name literal (e.g., `telnet`). + +--- + +#### 4) Render the menu in each view and add your new view function + +In every view function (`frontend_view`, `options_view`, and your new `test_view`) render the menu and include it into the page as `options_toggle`: + +```python +template_view_menu = module.templates.get_template('/control_view_menu.html') +current_view = module.get_current_view(dispatchers_steamid) +options_toggle = module.template_render_hook( + module, + template=template_view_menu, + views=VIEW_REGISTRY, + current_view=current_view, + steamid=dispatchers_steamid +) + +data_to_emit = module.template_render_hook( + module, + template=template_for_this_view, + options_toggle=options_toggle, + # ... other template params ... +) +``` + +Create the new view template (e.g., `view_test.html`) that uses the aside area to render the menu: +```jinja2 +
+
+ Some Title +
+
+ +
+ + + + + + + + + +
Test View
Test
+
+``` + +--- + +#### 5) Wire the server action to toggle views + +Each module has an action named `toggle__widget_view` that updates the current view for a dispatcher. Extend it to support your new action string (`show_`): + +`bot/modules//actions/toggle__widget_view.py` +```python +def main_function(module, event_data, dispatchers_steamid): + action = event_data[1].get('action', None) + event_data[1]['action_identifier'] = action_name + + if action == 'show_options': + current_view = 'options' + elif action == 'show_frontend': + current_view = 'frontend' + elif action == 'show_test': + current_view = 'test' + else: + module.callback_fail(callback_fail, module, event_data, dispatchers_steamid) + return + + module.set_current_view(dispatchers_steamid, { + 'current_view': current_view + }) + module.callback_success(callback_success, module, event_data, dispatchers_steamid) +``` + +Make sure the view ID you set here matches the view IDs used in `VIEW_REGISTRY` and `select_view`. + +--- + +#### 6) Reference implementation to compare + +Use these files as working examples: +- Locations menu macro: `bot/modules/locations/templates/jinja2_macros.html` +- Locations widget menu template: `bot/modules/locations/templates/manage_locations_widget/control_view_menu.html` +- Telnet macros with menu: `bot/modules/telnet/templates/jinja2_macros.html` +- Telnet menu template: `bot/modules/telnet/templates/telnet_log_widget/control_view_menu.html` +- Telnet widget with added view: `bot/modules/telnet/widgets/telnet_log_widget.py` +- Telnet action wiring: `bot/modules/telnet/actions/toggle_telnet_widget_view.py` +- Telnet new view template: `bot/modules/telnet/templates/telnet_log_widget/view_test.html` + +--- + +#### 7) Quick checklist + +- [ ] `VIEW_REGISTRY` contains your `` with a correct `action` (`show_`) and `include_in_menu = True`. +- [ ] `select_view` routes to `` by calling `_view`. +- [ ] The new `_view` renders `options_toggle` using `control_view_menu.html`. +- [ ] The new view has a template and includes `{{ options_toggle }}` in the aside. +- [ ] The module's `toggle__widget_view` action handles `show_` and sets `current_view` accordingly. +- [ ] Menu renders without errors and buttons switch between views (`back` returns to `frontend`). + +--- + +#### Notes + +- Keep naming consistent: action strings are `show_`, event name is `toggle__widget_view`. +- The base view `frontend` is usually not included in the menu (`include_in_menu: False`), but is the `default_view` used for the "back" behavior. +- Follow the module's existing code style and avoid unrelated refactors when introducing new views/buttons. diff --git a/bot/resources/configs/.env.example b/bot/resources/configs/.env.example new file mode 100644 index 0000000..cee629c --- /dev/null +++ b/bot/resources/configs/.env.example @@ -0,0 +1,74 @@ +# chrani-bot-tng Environment Configuration +# Copy this file to .env and adjust the values for your setup + +# ============================================================================ +# Application Settings +# ============================================================================ + +# Set to 'true' when running under gunicorn or other WSGI servers +RUNNING_UNDER_WSGI=true + +# Flask secret key - CHANGE THIS to a random string in production! +FLASK_SECRET_KEY=change-this-to-a-random-secret-key + +# ============================================================================ +# Server Configuration +# ============================================================================ + +# Host to bind to (use 0.0.0.0 for all interfaces, 127.0.0.1 for localhost only) +HOST=0.0.0.0 + +# Port to run on +PORT=5000 + +# ============================================================================ +# 7 Days to Die Server Settings +# ============================================================================ + +# Telnet connection settings for the game server +TELNET_HOST=localhost +TELNET_PORT=8081 +TELNET_PASSWORD=your-telnet-password + +# ============================================================================ +# Logging +# ============================================================================ + +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO + +# ============================================================================ +# Development Settings +# ============================================================================ + +# Enable Flask debug mode (DO NOT use in production!) +FLASK_DEBUG=false + +# Enable SocketIO debug mode +SOCKETIO_DEBUG=false + +# Enable engineio logger +ENGINEIO_LOGGER=false + +# ============================================================================ +# Production Settings +# ============================================================================ + +# Number of gunicorn workers (for WebSocket use 1 worker with gevent) +GUNICORN_WORKERS=1 + +# Gunicorn worker class (use 'gevent' for WebSocket support) +GUNICORN_WORKER_CLASS=gevent + +# Maximum number of concurrent connections per worker +GUNICORN_WORKER_CONNECTIONS=1000 + +# Request timeout in seconds +GUNICORN_TIMEOUT=120 + +# ============================================================================ +# Database/Storage (if applicable) +# ============================================================================ + +# Add any database connection strings or storage paths here +# DATA_DIR=/var/lib/chrani-bot-tng diff --git a/bot/resources/configs/chrani-bot-tng.service b/bot/resources/configs/chrani-bot-tng.service new file mode 100644 index 0000000..3a1c8f3 --- /dev/null +++ b/bot/resources/configs/chrani-bot-tng.service @@ -0,0 +1,51 @@ +[Unit] +Description=chrani-bot-tng - 7 Days to Die Server Management Bot +After=network.target + +[Service] +Type=notify +# The specific user that our service will run as +# CHANGE THIS to your actual username +User=your-username +Group=your-username + +# Set working directory +WorkingDirectory=/path/to/chrani-bot-tng + +# Environment variables +Environment="PATH=/path/to/chrani-bot-tng/venv/bin" +Environment="RUNNING_UNDER_WSGI=true" + +# If you're using a .env file for configuration +# EnvironmentFile=/path/to/chrani-bot-tng/.env + +# The command to start the service +ExecStart=/path/to/chrani-bot-tng/venv/bin/gunicorn -c gunicorn.conf.py wsgi:application + +# Restart policy +Restart=always +RestartSec=10 + +# Performance and security settings +RuntimeDirectory=chrani-bot-tng +RuntimeDirectoryMode=755 + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=chrani-bot-tng + +# Security hardening (optional but recommended) +NoNewPrivileges=true +PrivateTmp=true + +# Resource limits (adjust as needed) +LimitNOFILE=65536 +LimitNPROC=512 + +# Timeout for start/stop +TimeoutStartSec=300 +TimeoutStopSec=300 + +[Install] +WantedBy=multi-user.target diff --git a/bot/resources/configs/gunicorn.conf.py b/bot/resources/configs/gunicorn.conf.py new file mode 100644 index 0000000..53e3ca9 --- /dev/null +++ b/bot/resources/configs/gunicorn.conf.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +""" +Gunicorn Configuration File for chrani-bot-tng + +This configuration is optimized for running the bot with Flask-SocketIO and gevent. +""" +import multiprocessing +import os + +# Server Socket +bind = "0.0.0.0:5000" +backlog = 2048 + +# Worker Processes +# For WebSocket support with gevent, use only 1 worker +# Multiple workers don't work well with socket.io state +workers = 1 +worker_class = "gevent" +worker_connections = 1000 +max_requests = 0 # Disable automatic worker restart +max_requests_jitter = 0 +timeout = 120 +keepalive = 5 + +# Logging +accesslog = "-" # Log to stdout +errorlog = "-" # Log to stderr +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + +# Process Naming +proc_name = "chrani-bot-tng" + +# Server Mechanics +daemon = False +pidfile = None +umask = 0 +user = None +group = None +tmp_upload_dir = None + +# SSL (uncomment and configure if using HTTPS directly with gunicorn) +# keyfile = "/path/to/keyfile" +# certfile = "/path/to/certfile" +# ca_certs = "/path/to/ca_certs" +# cert_reqs = 0 +# ssl_version = 2 +# ciphers = None + +# Server Hooks +def on_starting(server): + """ + Called just before the master process is initialized. + """ + print("=" * 60) + print("chrani-bot-tng is starting...") + print("=" * 60) + +def on_reload(server): + """ + Called to recycle workers during a reload via SIGHUP. + """ + print("Reloading workers...") + +def when_ready(server): + """ + Called just after the server is started. + """ + print("=" * 60) + print("chrani-bot-tng is ready to accept connections") + print(f"Listening on: {bind}") + print("=" * 60) + +def pre_fork(server, worker): + """ + Called just before a worker is forked. + """ + pass + +def post_fork(server, worker): + """ + Called just after a worker has been forked. + """ + print(f"Worker spawned (pid: {worker.pid})") + +def post_worker_init(worker): + """ + Called just after a worker has initialized the application. + """ + print(f"Worker initialized (pid: {worker.pid})") + +def worker_int(worker): + """ + Called just after a worker received the SIGINT or SIGQUIT signal. + """ + print(f"Worker received INT or QUIT signal (pid: {worker.pid})") + +def worker_abort(worker): + """ + Called when a worker received the SIGABRT signal. + """ + print(f"Worker received SIGABRT signal (pid: {worker.pid})") + +def pre_exec(server): + """ + Called just before a new master process is forked. + """ + print("Forking new master process...") + +def pre_request(worker, req): + """ + Called just before a worker processes the request. + """ + worker.log.debug(f"{req.method} {req.path}") + +def post_request(worker, req, environ, resp): + """ + Called after a worker processes the request. + """ + pass + +def child_exit(server, worker): + """ + Called just after a worker has been exited. + """ + print(f"Worker exited (pid: {worker.pid})") + +def worker_exit(server, worker): + """ + Called just after a worker has been exited. + """ + print(f"Worker process exiting (pid: {worker.pid})") + +def nworkers_changed(server, new_value, old_value): + """ + Called just after num_workers has been changed. + """ + print(f"Number of workers changed from {old_value} to {new_value}") + +def on_exit(server): + """ + Called just before exiting gunicorn. + """ + print("=" * 60) + print("chrani-bot-tng is shutting down...") + print("=" * 60) diff --git a/bot/resources/configs/nginx.conf.example b/bot/resources/configs/nginx.conf.example new file mode 100644 index 0000000..c5f45c6 --- /dev/null +++ b/bot/resources/configs/nginx.conf.example @@ -0,0 +1,156 @@ +# nginx configuration for chrani-bot-tng +# +# INSTALLATION: +# 1. Copy this file to /etc/nginx/sites-available/chrani-bot-tng +# 2. Update the server_name to match your domain +# 3. Update the paths to SSL certificates if using HTTPS +# 4. Create a symlink: sudo ln -s /etc/nginx/sites-available/chrani-bot-tng /etc/nginx/sites-enabled/ +# 5. Test config: sudo nginx -t +# 6. Reload nginx: sudo systemctl reload nginx + +# Upstream configuration for gunicorn +upstream chrani_bot_app { + # Use Unix socket for better performance (recommended) + # Make sure this matches the bind setting in gunicorn.conf.py + server 127.0.0.1:5000 fail_timeout=0; + + # Alternative: Unix socket (requires changing gunicorn bind setting) + # server unix:/var/run/chrani-bot-tng/gunicorn.sock fail_timeout=0; +} + +# HTTP Server (redirects to HTTPS) +server { + listen 80; + listen [::]:80; + server_name your-domain.com www.your-domain.com; + + # Redirect all HTTP traffic to HTTPS + return 301 https://$server_name$request_uri; +} + +# HTTPS Server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name your-domain.com www.your-domain.com; + + # SSL Configuration + # Update these paths with your actual certificate paths + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # SSL Security Settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Logging + access_log /var/log/nginx/chrani-bot-tng-access.log; + error_log /var/log/nginx/chrani-bot-tng-error.log; + + # Max upload size (adjust as needed) + client_max_body_size 4M; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Main location block - proxy to gunicorn + location / { + proxy_pass http://chrani_bot_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Disable buffering for better real-time response + proxy_buffering off; + proxy_redirect off; + } + + # WebSocket support for Socket.IO + location /socket.io/ { + proxy_pass http://chrani_bot_app/socket.io/; + proxy_http_version 1.1; + + # WebSocket specific headers + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Standard proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts for WebSocket + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + + # Disable buffering for WebSocket + proxy_buffering off; + } + + # Static files (if you have any) + location /static/ { + alias /path/to/chrani-bot-tng/static/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Favicon + location = /favicon.ico { + access_log off; + log_not_found off; + } + + # Robots.txt + location = /robots.txt { + access_log off; + log_not_found off; + } +} + +# Alternative: HTTP-only configuration (for local/dev use) +# Uncomment this and comment out the HTTPS server above if not using SSL +# +# server { +# listen 80; +# listen [::]:80; +# server_name your-domain.com www.your-domain.com; +# +# access_log /var/log/nginx/chrani-bot-tng-access.log; +# error_log /var/log/nginx/chrani-bot-tng-error.log; +# +# client_max_body_size 4M; +# +# location / { +# proxy_pass http://chrani_bot_app; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_buffering off; +# proxy_redirect off; +# } +# +# location /socket.io/ { +# proxy_pass http://chrani_bot_app/socket.io/; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_buffering off; +# } +# } diff --git a/bot/resources/services/analyze_callback_usage.py b/bot/resources/services/analyze_callback_usage.py new file mode 100755 index 0000000..83a2af3 --- /dev/null +++ b/bot/resources/services/analyze_callback_usage.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +Callback Dict Usage Analyzer + +Scans the codebase for all callback_dict usage: +- dom.data.upsert() calls +- dom.data.append() calls +- dom.data.remove_key_by_path() calls +- widget_meta["handlers"] registrations + +Generates a complete inventory for Phase 0 of the refactoring plan. +""" + +import os +import re +from pathlib import Path +from typing import List, Dict, Tuple + + +class CallbackAnalyzer: + def __init__(self, root_dir: str): + self.root_dir = Path(root_dir) + self.results = { + "upsert_calls": [], + "append_calls": [], + "remove_calls": [], + "handler_registrations": [] + } + + def analyze(self): + """Run all analysis passes.""" + print("🔍 Analyzing callback_dict usage...") + print(f"📁 Root directory: {self.root_dir}\n") + + # Find all Python files in bot/modules + modules_dir = self.root_dir / "bot" / "modules" + if not modules_dir.exists(): + print(f"❌ Error: {modules_dir} does not exist!") + return + + python_files = list(modules_dir.rglob("*.py")) + print(f"📄 Found {len(python_files)} Python files\n") + + # Analyze each file + for py_file in python_files: + self._analyze_file(py_file) + + # Print results + self._print_results() + + # Generate markdown report + self._generate_report() + + def _analyze_file(self, file_path: Path): + """Analyze a single Python file.""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + lines = content.split('\n') + + relative_path = file_path.relative_to(self.root_dir) + + # Find upsert calls + self._find_upsert_calls(relative_path, content, lines) + + # Find append calls + self._find_append_calls(relative_path, content, lines) + + # Find remove calls + self._find_remove_calls(relative_path, content, lines) + + # Find handler registrations + self._find_handler_registrations(relative_path, content, lines) + + except Exception as e: + print(f"⚠️ Error analyzing {file_path}: {e}") + + def _find_upsert_calls(self, file_path: Path, content: str, lines: List[str]): + """Find all dom.data.upsert() calls.""" + # Pattern: module.dom.data.upsert( or self.dom.data.upsert( + pattern = r'(module|self)\.dom\.data\.upsert\(' + + for match in re.finditer(pattern, content): + line_num = content[:match.start()].count('\n') + 1 + + # Try to extract callback levels + min_level, max_level = self._extract_callback_levels(lines, line_num) + + # Try to determine data depth + depth = self._estimate_data_depth(lines, line_num) + + self.results["upsert_calls"].append({ + "file": str(file_path), + "line": line_num, + "min_callback_level": min_level, + "max_callback_level": max_level, + "estimated_depth": depth + }) + + def _find_append_calls(self, file_path: Path, content: str, lines: List[str]): + """Find all dom.data.append() calls.""" + pattern = r'(module|self)\.dom\.data\.append\(' + + for match in re.finditer(pattern, content): + line_num = content[:match.start()].count('\n') + 1 + + self.results["append_calls"].append({ + "file": str(file_path), + "line": line_num + }) + + def _find_remove_calls(self, file_path: Path, content: str, lines: List[str]): + """Find all dom.data.remove_key_by_path() calls.""" + pattern = r'(module|self)\.dom\.data\.remove_key_by_path\(' + + for match in re.finditer(pattern, content): + line_num = content[:match.start()].count('\n') + 1 + + self.results["remove_calls"].append({ + "file": str(file_path), + "line": line_num + }) + + def _find_handler_registrations(self, file_path: Path, content: str, lines: List[str]): + """Find all widget_meta handler registrations.""" + # Look for widget_meta = { ... "handlers": { ... } ... } + + if 'widget_meta' not in content: + return + + if '"handlers"' not in content and "'handlers'" not in content: + return + + # Find line with handlers dict + in_handlers = False + handlers_start = None + + for i, line in enumerate(lines): + if '"handlers"' in line or "'handlers'" in line: + in_handlers = True + handlers_start = i + 1 + continue + + if in_handlers: + # Look for path patterns + # Pattern: "path/pattern": handler_function, + path_match = re.search(r'["\']([^"\']+)["\']:\s*(\w+)', line) + + if path_match: + path_pattern = path_match.group(1) + handler_name = path_match.group(2) + + # Calculate depth + depth = path_pattern.count('/') + + self.results["handler_registrations"].append({ + "file": str(file_path), + "line": i + 1, + "path_pattern": path_pattern, + "handler_function": handler_name, + "depth": depth + }) + + # Check if we've left the handlers dict + if '}' in line and ',' not in line: + in_handlers = False + + def _extract_callback_levels(self, lines: List[str], start_line: int) -> Tuple[str, str]: + """Try to extract min_callback_level and max_callback_level from upsert call.""" + min_level = "None" + max_level = "None" + + # Look at next ~10 lines for callback level params + for i in range(start_line - 1, min(start_line + 10, len(lines))): + line = lines[i] + + if 'min_callback_level' in line: + match = re.search(r'min_callback_level\s*=\s*(\d+|None)', line) + if match: + min_level = match.group(1) + + if 'max_callback_level' in line: + match = re.search(r'max_callback_level\s*=\s*(\d+|None)', line) + if match: + max_level = match.group(1) + + # Stop at closing paren + if ')' in line: + break + + return min_level, max_level + + def _estimate_data_depth(self, lines: List[str], start_line: int) -> int: + """Estimate the depth of data being upserted by counting dict nesting.""" + depth = 0 + brace_count = 0 + + # Look backwards for opening brace + for i in range(start_line - 1, max(0, start_line - 30), -1): + line = lines[i] + + if 'upsert({' in line or 'upsert( {' in line: + # Count colons/keys in following lines until we reach reasonable depth + for j in range(i, min(i + 50, len(lines))): + check_line = lines[j] + + # Count dictionary depth by tracking braces + brace_count += check_line.count('{') + brace_count -= check_line.count('}') + + if brace_count < 0: + break + + # Rough estimate: each key-value pair increases depth + if '":' in check_line or "\':" in check_line: + depth += 1 + + break + + # Depth is usually around 4-6 for locations/players + return min(depth, 10) # Cap at reasonable number + + def _print_results(self): + """Print analysis results to console.""" + print("\n" + "=" * 70) + print("📊 ANALYSIS RESULTS") + print("=" * 70 + "\n") + + print(f"🔹 Upsert Calls: {len(self.results['upsert_calls'])}") + print(f"🔹 Append Calls: {len(self.results['append_calls'])}") + print(f"🔹 Remove Calls: {len(self.results['remove_calls'])}") + print(f"🔹 Handler Registrations: {len(self.results['handler_registrations'])}") + + print("\n" + "-" * 70) + print("UPSERT CALLS BY CALLBACK LEVELS:") + print("-" * 70) + + # Group by callback levels + level_groups = {} + for call in self.results["upsert_calls"]: + key = f"min={call['min_callback_level']}, max={call['max_callback_level']}" + if key not in level_groups: + level_groups[key] = [] + level_groups[key].append(call) + + for levels, calls in sorted(level_groups.items()): + print(f"\n{levels}: {len(calls)} calls") + for call in calls: + print(f" 📄 {call['file']}:{call['line']}") + + print("\n" + "-" * 70) + print("HANDLER REGISTRATIONS BY DEPTH:") + print("-" * 70) + + # Group by depth + depth_groups = {} + for handler in self.results["handler_registrations"]: + depth = handler['depth'] + if depth not in depth_groups: + depth_groups[depth] = [] + depth_groups[depth].append(handler) + + for depth in sorted(depth_groups.keys()): + handlers = depth_groups[depth] + print(f"\nDepth {depth}: {len(handlers)} handlers") + for h in handlers: + print(f" 📄 {h['file']}:{h['line']}") + print(f" Pattern: {h['path_pattern']}") + print(f" Handler: {h['handler_function']}") + + def _generate_report(self): + """Generate markdown report.""" + report_path = self.root_dir / "CALLBACK_DICT_INVENTORY.md" + + with open(report_path, 'w', encoding='utf-8') as f: + f.write("# Callback Dict Usage Inventory\n\n") + f.write("Generated by analyze_callback_usage.py\n\n") + + f.write("## Summary\n\n") + f.write(f"- **Upsert Calls:** {len(self.results['upsert_calls'])}\n") + f.write(f"- **Append Calls:** {len(self.results['append_calls'])}\n") + f.write(f"- **Remove Calls:** {len(self.results['remove_calls'])}\n") + f.write(f"- **Handler Registrations:** {len(self.results['handler_registrations'])}\n\n") + + f.write("---\n\n") + + f.write("## Upsert Calls\n\n") + f.write("| File | Line | Min Level | Max Level | Est. Depth |\n") + f.write("|------|------|-----------|-----------|------------|\n") + for call in self.results["upsert_calls"]: + f.write(f"| {call['file']} | {call['line']} | " + f"{call['min_callback_level']} | {call['max_callback_level']} | " + f"{call['estimated_depth']} |\n") + + f.write("\n---\n\n") + + f.write("## Append Calls\n\n") + f.write("| File | Line |\n") + f.write("|------|------|\n") + for call in self.results["append_calls"]: + f.write(f"| {call['file']} | {call['line']} |\n") + + f.write("\n---\n\n") + + f.write("## Remove Calls\n\n") + f.write("| File | Line |\n") + f.write("|------|------|\n") + for call in self.results["remove_calls"]: + f.write(f"| {call['file']} | {call['line']} |\n") + + f.write("\n---\n\n") + + f.write("## Handler Registrations\n\n") + f.write("| File | Line | Depth | Path Pattern | Handler Function |\n") + f.write("|------|------|-------|--------------|------------------|\n") + for handler in self.results["handler_registrations"]: + f.write(f"| {handler['file']} | {handler['line']} | " + f"{handler['depth']} | `{handler['path_pattern']}` | " + f"`{handler['handler_function']}` |\n") + + f.write("\n---\n\n") + f.write("## Next Steps\n\n") + f.write("1. Review each upsert call - does it send complete or partial data?\n") + f.write("2. Review each handler - does it expect complete or partial data?\n") + f.write("3. Identify mismatches that will benefit from enrichment\n") + f.write("4. Plan which files need updating in Phase 2 and Phase 3\n") + + print(f"\n✅ Report generated: {report_path}") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1: + root = sys.argv[1] + else: + # Assume we're in the scripts directory + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + analyzer = CallbackAnalyzer(root) + analyzer.analyze() + + print("\n✨ Analysis complete!") + print("📋 Review CALLBACK_DICT_INVENTORY.md for detailed results") + print("📖 See CALLBACK_DICT_REFACTOR_PLAN.md for next steps\n") diff --git a/bot/resources/services/debug_telnet.py b/bot/resources/services/debug_telnet.py new file mode 100644 index 0000000..29b5a3c --- /dev/null +++ b/bot/resources/services/debug_telnet.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Debug script to see raw telnet responses from 7D2D server. +This helps update the regex patterns in the action files. +""" +import telnetlib +import json +import time +import re + +# Load config +with open('bot/options/module_telnet.json', 'r') as f: + config = json.load(f) + +HOST = config['host'] +PORT = config['port'] +PASSWORD = config['password'] + +print(f"Connecting to {HOST}:{PORT}...") + +# Connect +tn = telnetlib.Telnet(HOST, PORT, timeout=5) + +# Wait for password prompt +response = tn.read_until(b"Please enter password:", timeout=3) +print("Got password prompt") + +# Send password +tn.write(PASSWORD.encode('ascii') + b"\r\n") + +# Wait for welcome message +time.sleep(1) +welcome = tn.read_very_eager().decode('utf-8') +print("Connected!\n") + +# Commands to test +commands = [ + 'admin list', + 'lp', + 'gettime', + 'getgamepref', + 'getgamestat', + 'listents' +] + +for cmd in commands: + print(f"\n{'='*80}") + print(f"COMMAND: {cmd}") + print('='*80) + + # Send command + tn.write(cmd.encode('ascii') + b"\r\n") + + # Wait a bit for response + time.sleep(2) + + # Read response + response = tn.read_very_eager().decode('utf-8') + + print("RAW RESPONSE:") + print(repr(response)) # Show with escape characters + print("\nFORMATTED:") + print(response) + print() + +tn.write(b"exit\r\n") +tn.close() + +print("\n" + "="*80) +print("Done! Use these responses to update the regex patterns.") +print("="*80) diff --git a/bot/resources/services/test_all_commands.py b/bot/resources/services/test_all_commands.py new file mode 100644 index 0000000..c414af0 --- /dev/null +++ b/bot/resources/services/test_all_commands.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Comprehensive telnet command tester for 7D2D server +Tests all bot commands and validates regex patterns +""" +import telnetlib +import json +import time +import re +from pathlib import Path + +# Load config +config_path = Path(__file__).parent / 'bot' / 'options' / 'module_telnet.json' +if config_path.exists(): + with open(config_path, 'r') as f: + config = json.load(f) +else: + # Manual config if file doesn't exist + config = { + 'host': input('Server IP: '), + 'port': int(input('Server Port: ')), + 'password': input('Telnet Password: ') + } + +print(f"\n{'='*80}") +print(f"Connecting to {config['host']}:{config['port']}") +print(f"{'='*80}\n") + +# Connect +tn = telnetlib.Telnet(config['host'], config['port'], timeout=10) + +# Wait for password prompt +time.sleep(0.5) +output = tn.read_very_eager().decode('ascii', errors='ignore') +print('[CONNECTION] Established') + +# Send password +tn.write((config['password'] + '\n').encode('ascii')) +time.sleep(0.5) +output = tn.read_very_eager().decode('ascii', errors='ignore') +print('[AUTH] Authenticated\n') + +# Test commands with their expected regex patterns +commands = [ + { + 'name': 'admin list', + 'command': 'admin list', + 'regex': r"Executing\scommand\s\'admin list\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P(?:Defined User Permissions\:.*?(?=Defined Group Permissions|$)))", + 'wait': 1, + 'description': 'Get admin list' + }, + { + 'name': 'lp (getplayers)', + 'command': 'lp', + 'regex': r"Executing\scommand\s\'lp\'\sby\sTelnet\sfrom\s" + r"(?P.*?)\r?\n" + r"(?P[\s\S]*?)" + r"Total\sof\s(?P\d{1,2})\sin\sthe\sgame", + 'wait': 1, + 'description': 'Get player list' + }, + { + 'name': 'gettime', + 'command': 'gettime', + 'regex': r"Day\s(?P\d{1,5}),\s(?P\d{1,2}):(?P\d{1,2})", + 'wait': 1, + 'description': 'Get game time' + }, + { + 'name': 'listents (getentities)', + 'command': 'listents', + 'regex': r"Executing\scommand\s\'listents\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P[\s\S]*?)" + r"Total\sof\s(?P\d{1,3})\sin\sthe\sgame", + 'wait': 1, + 'description': 'Get entity list' + }, + { + 'name': 'getgamepref', + 'command': 'getgamepref', + 'regex': r"Executing\scommand\s\'getgamepref\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P(?:GamePref\..*?\r?\n)+)", + 'wait': 2, + 'description': 'Get game preferences' + }, + { + 'name': 'getgamestat', + 'command': 'getgamestat', + 'regex': r"Executing\scommand\s\'getgamestat\'\sby\sTelnet\sfrom\s(?P.*?)\r?\n" + r"(?P(?:GameStat\..*?\r?\n)+)", + 'wait': 2, + 'description': 'Get game statistics' + }, + { + 'name': 'version', + 'command': 'version', + 'regex': None, # Just check raw output + 'wait': 1, + 'description': 'Get server version' + }, + { + 'name': 'help', + 'command': 'help', + 'regex': None, # Just check raw output + 'wait': 2, + 'description': 'Get available commands' + } +] + +results = { + 'passed': [], + 'failed': [], + 'no_regex': [] +} + +for test in commands: + print(f"\n{'='*80}") + print(f"TEST: {test['name']}") + print(f"Description: {test['description']}") + print(f"{'='*80}") + + # Send command + print(f"\n>>> Sending: {test['command']}") + tn.write((test['command'] + '\n').encode('ascii')) + + # Wait for response + time.sleep(test['wait']) + output = tn.read_very_eager().decode('ascii', errors='ignore') + + # Show raw output + print(f"\n--- RAW OUTPUT (repr) ---") + print(repr(output)) + print(f"\n--- RAW OUTPUT (formatted) ---") + print(output) + + # Test regex if provided + if test['regex']: + print(f"\n--- REGEX TEST ---") + print(f"Pattern: {test['regex'][:100]}...") + + matches = list(re.finditer(test['regex'], output, re.MULTILINE | re.DOTALL)) + + if matches: + print(f"✓ REGEX MATCHED! ({len(matches)} match(es))") + for i, match in enumerate(matches, 1): + print(f"\nMatch {i}:") + for group_name, group_value in match.groupdict().items(): + value_preview = repr(group_value)[:100] + print(f" {group_name}: {value_preview}") + results['passed'].append(test['name']) + else: + print(f"✗ REGEX FAILED - NO MATCH!") + results['failed'].append(test['name']) + else: + print(f"\n--- NO REGEX TEST (raw output only) ---") + results['no_regex'].append(test['name']) + + print(f"\n{'='*80}\n") + time.sleep(0.5) # Small delay between commands + +# Close connection +tn.close() + +# Summary +print(f"\n\n{'='*80}") +print("SUMMARY") +print(f"{'='*80}") +print(f"✓ Passed: {len(results['passed'])} - {', '.join(results['passed']) if results['passed'] else 'none'}") +print(f"✗ Failed: {len(results['failed'])} - {', '.join(results['failed']) if results['failed'] else 'none'}") +print(f"- No test: {len(results['no_regex'])} - {', '.join(results['no_regex']) if results['no_regex'] else 'none'}") +print(f"{'='*80}\n") + +if results['failed']: + print("⚠️ SOME TESTS FAILED - Regex patterns need to be fixed!") + exit(1) +else: + print("✓ All regex tests passed!") + exit(0) diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..6c1ae27 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +""" +Gunicorn Configuration File for chrani-bot-tng (project root) + +This configuration is optimized for running the bot with Flask-SocketIO and gevent. + +Note: +- This file intentionally lives in the project root for tooling convenience (e.g., IDE run configs, deployment commands). +- The read-only resources copy is kept as reference documentation. +""" +# Server Socket +bind = "0.0.0.0:5000" +backlog = 2048 + +# Worker Processes +# For WebSocket support with gevent, use only 1 worker +# Multiple workers don't work well with socket.io state +workers = 1 +# Use gevent-websocket worker so Flask-SocketIO can upgrade to WebSocket under gunicorn +worker_class = "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" +worker_connections = 1000 +max_requests = 0 # Disable automatic worker restart +max_requests_jitter = 0 +timeout = 120 +keepalive = 5 + +# Logging +accesslog = "-" # Log to stdout +errorlog = "-" # Log to stderr +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + +# Process Naming +proc_name = "chrani-bot-tng" + +# Server Mechanics +daemon = False +pidfile = None +umask = 0 +user = None +group = None +tmp_upload_dir = None + +# SSL (uncomment and configure if using HTTPS directly with gunicorn) +# keyfile = "/path/to/keyfile" +# certfile = "/path/to/certfile" +# ca_certs = "/path/to/ca_certs" +# cert_reqs = 0 +# ssl_version = 2 +# ciphers = None + +# Server Hooks +def on_starting(server): + """ + Called just before the master process is initialized. + """ + print("=" * 60) + print("chrani-bot-tng is starting...") + print("=" * 60) + + +def on_reload(server): + """ + Called to recycle workers during a reload via SIGHUP. + """ + print("Reloading workers...") + + +def when_ready(server): + """ + Called just after the server is started. + """ + print("=" * 60) + print("chrani-bot-tng is ready to accept connections") + print(f"Listening on: {bind}") + print("=" * 60) + + +def pre_fork(server, worker): + """ + Called just before a worker is forked. + """ + pass + + +def post_fork(server, worker): + """ + Called just after a worker has been forked. + """ + print(f"Worker spawned (pid: {worker.pid})") + + +def post_worker_init(worker): + """ + Called just after a worker has initialized the application. + """ + print(f"Worker initialized (pid: {worker.pid})") + + +def worker_int(worker): + """ + Called just after a worker received the SIGINT or SIGQUIT signal. + """ + print(f"Worker received INT or QUIT signal (pid: {worker.pid})") + + +def worker_abort(worker): + """ + Called when a worker received the SIGABRT signal. + """ + print(f"Worker received SIGABRT signal (pid: {worker.pid})") + + +def pre_exec(server): + """ + Called just before a new master process is forked. + """ + print("Forking new master process...") + + +def pre_request(worker, req): + """ + Called just before a worker processes the request. + """ + worker.log.debug(f"{req.method} {req.path}") + + +def post_request(worker, req, environ, resp): + """ + Called after a worker processes the request. + """ + pass + + +def child_exit(server, worker): + """ + Called just after a worker has been exited. + """ + print(f"Worker exited (pid: {worker.pid})") + + +def worker_exit(server, worker): + """ + Called just after a worker has been exited. + """ + print(f"Worker process exiting (pid: {worker.pid})") + + +def nworkers_changed(server, new_value, old_value): + """ + Called just after num_workers has been changed. + """ + print(f"Number of workers changed from {old_value} to {new_value}") + + +def on_exit(server): + """ + Called just before exiting gunicorn. + """ + print("=" * 60) + print("chrani-bot-tng is shutting down...") + print("=" * 60) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..765bc56 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Web Framework +flask>=3.0.0,<4.0.0 +flask-login>=0.6.3,<0.7.0 +flask-socketio>=5.3.0,<6.0.0 + +# Template Engine +jinja2>=3.1.0,<4.0.0 + +# HTTP Client +requests>=2.31.0,<3.0.0 + +# Async/Concurrency +gevent>=24.2.0,<25.0.0 +gevent-websocket>=0.10.1 + +# WSGI Server for Production +gunicorn>=21.2.0,<22.0.0 + +# Environment Configuration +python-dotenv>=1.0.0,<2.0.0 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..e298ecb --- /dev/null +++ b/wsgi.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +WSGI Entry Point for Gunicorn + +This file is used when running the application with gunicorn. +It sets up all modules and exposes the Flask app for the WSGI server. +""" +import os +from bot import setup_modules, start_modules, started_modules_dict + +# Signal to modules that we're running under a WSGI server +os.environ['RUNNING_UNDER_WSGI'] = 'true' + +# Initialize all modules +setup_modules() +start_modules() + +# Get the webserver module and its Flask app +webserver_module = started_modules_dict.get('module_webserver') +if not webserver_module: + raise RuntimeError("Webserver module not found! Make sure it's properly configured.") + +# Expose the Flask WSGI application for gunicorn +# With Socket.IO we serve WebSockets via gunicorn's gevent-websocket worker. +# Therefore the WSGI callable must be the underlying Flask app, not the SocketIO object. +application = webserver_module.app + +# For debugging +if __name__ == "__main__": + print("This file is meant to be run with gunicorn.") + print("Example: gunicorn -c gunicorn.conf.py wsgi:application")