From 472f0812e77dc311f55ca4681f9eb4dc595b5d40 Mon Sep 17 00:00:00 2001 From: wwevo Date: Fri, 21 Nov 2025 07:26:02 +0100 Subject: [PATCH] Release 0.9.0 --- .github/FUNDING.yml | 1 + .gitignore | 48 + .junie/guidelines.md | 25 + .run/app.run.xml | 26 + .run/gunicorn-dev.run.xml | 27 + .run/gunicorn.run.xml | 27 + DOM_HELPER_MIGRATION.md | 337 ++++++ LICENSE | 674 +++++++++++ README.md | 67 ++ app.py | 10 + bot/__init__.py | 105 ++ bot/constants.py | 98 ++ bot/git.txt | 31 + bot/logger.py | 311 +++++ bot/mixins/__init__.py | 1 + bot/mixins/action.py | 106 ++ bot/mixins/template.py | 15 + bot/mixins/trigger.py | 62 + bot/mixins/widget.py | 87 ++ bot/module.py | 103 ++ bot/modules/__init__.py | 11 + bot/modules/dom/__init__.py | 73 ++ bot/modules/dom/callback_dict.py | 514 ++++++++ bot/modules/dom_management/__init__.py | 208 ++++ bot/modules/dom_management/actions/delete.py | 66 ++ bot/modules/dom_management/actions/select.py | 120 ++ .../control_action_delete_button.html | 13 + .../templates/control_select_link.html | 24 + .../templates/jinja2_macros.html | 20 + .../templates/modal_confirm_delete.html | 75 ++ .../modal_confirm_delete_cancel_button.html | 11 + .../modal_confirm_delete_confirm_button.html | 12 + bot/modules/game_environment/__init__.py | 101 ++ .../actions/cancel_shutdown.py | 49 + .../actions/force_shutdown.py | 52 + .../game_environment/actions/getentities.py | 136 +++ .../game_environment/actions/getgameprefs.py | 108 ++ .../game_environment/actions/getgamestats.py | 88 ++ .../game_environment/actions/gettime.py | 151 +++ .../actions/manage_entities.py | 102 ++ .../game_environment/actions/say_to_all.py | 59 + .../actions/schedule_shutdown.py | 50 + .../game_environment/actions/shutdown.py | 70 ++ .../actions/toggle_entities_widget_view.py | 46 + .../actions/update_bloodmoon_date.py | 51 + .../commands/when_is_hordenight.py | 55 + .../view_frontend.html | 33 + .../gametime_widget/view_frontend.html | 19 + .../templates/jinja2_macros.html | 20 + .../control_switch_options_view.html | 9 + .../control_switch_view.html | 3 + .../manage_entities_widget/table_footer.html | 7 + .../manage_entities_widget/table_header.html | 10 + .../manage_entities_widget/table_row.html | 25 + .../manage_entities_widget/view_frontend.html | 29 + .../manage_entities_widget/view_options.html | 27 + .../triggers/new_bloodmoon.py | 35 + .../triggers/shutdown_handler.py | 33 + .../widgets/gameserver_status_widget.py | 88 ++ .../widgets/gettime_widget.py | 103 ++ .../location_change_announcer_widget.py | 94 ++ .../widgets/manage_entities_widget.py | 406 +++++++ bot/modules/locations/__init__.py | 252 ++++ bot/modules/locations/actions/bc-export.py | 55 + bot/modules/locations/actions/bc-import.py | 97 ++ .../locations/actions/edit_location.py | 82 ++ bot/modules/locations/actions/onslaught.py | 48 + .../actions/teleport_to_coordinates.py | 42 + .../locations/actions/toggle_enabled_flag.py | 59 + .../actions/toggle_locations_widget_view.py | 72 ++ .../locations/commands/add_location.py | 64 + .../locations/commands/export_location.py | 77 ++ .../locations/commands/import_location.py | 78 ++ .../locations/commands/send_me_home.py | 59 + .../locations/commands/send_me_to_location.py | 102 ++ .../locations/commands/start_onslaught.py | 116 ++ .../locations/commands/take_me_to_my_grave.py | 64 + bot/modules/locations/commands/where_am_i.py | 100 ++ .../locations/templates/jinja2_macros.html | 56 + .../control_edit_link.html | 12 + .../control_enabled_link.html | 18 + .../control_player_location.html | 10 + .../control_switch_create_new_view.html | 9 + .../control_switch_map_view.html | 12 + .../control_switch_options_view.html | 9 + ...control_switch_special_locations_view.html | 9 + .../control_switch_view.html | 7 + .../control_view_menu.html | 11 + .../manage_locations_widget/table_footer.html | 5 + .../manage_locations_widget/table_header.html | 9 + .../manage_locations_widget/table_row.html | 18 + .../view_create_new.html | 34 + .../view_create_new_control_send_data.html | 57 + .../view_create_new_identifier.html | 35 + .../view_create_new_select_shape.html | 83 ++ .../view_create_new_select_type.html | 106 ++ .../view_create_new_set_coordinates.html | 55 + .../view_create_new_set_dimensions.html | 89 ++ .../view_create_new_set_name.html | 32 + ...w_create_new_set_teleport_coordinates.html | 54 + .../view_frontend.html | 29 + .../manage_locations_widget/view_map.html | 474 ++++++++ .../manage_locations_widget/view_options.html | 27 + .../templates/webmap/location_actions.html | 172 +++ .../templates/webmap/location_shapes.html | 143 +++ .../webmap/location_update_handler.html | 32 + .../triggers/location_update_on_map.py | 113 ++ bot/modules/locations/triggers/player_died.py | 61 + bot/modules/locations/triggers/playerspawn.py | 63 + bot/modules/locations/triggers/zombiespawn.py | 86 ++ .../widgets/manage_locations_widget.py | 1041 +++++++++++++++++ bot/modules/permissions/__init__.py | 202 ++++ .../permissions/actions/set_authentication.py | 75 ++ .../permissions/actions/set_player_mute.py | 89 ++ bot/modules/permissions/commands/password.py | 57 + .../permissions/triggers/entered_telnet.py | 68 ++ .../triggers/player_authentication_change.py | 43 + .../permissions/triggers/player_moved.py | 62 + bot/modules/players/__init__.py | 67 ++ bot/modules/players/actions/getadmins.py | 79 ++ bot/modules/players/actions/getplayers.py | 246 ++++ bot/modules/players/actions/kick_player.py | 107 ++ bot/modules/players/actions/say_to_player.py | 74 ++ .../players/actions/teleport_player.py | 114 ++ .../actions/toggle_player_authentication.py | 50 + .../players/actions/toggle_player_mute.py | 91 ++ .../actions/toggle_players_widget_view.py | 50 + .../actions/update_player_permission_level.py | 64 + .../players/templates/jinja2_macros.html | 20 + .../control_info_link.html | 10 + .../control_kick_link.html | 13 + .../control_switch_options_view.html | 9 + .../control_switch_view.html | 3 + .../modal_confirm_kick.html | 43 + .../modal_confirm_kick_send_data.html | 15 + .../manage_players_widget/table_footer.html | 7 + .../manage_players_widget/table_header.html | 13 + .../manage_players_widget/table_row.html | 30 + .../manage_players_widget/view_frontend.html | 33 + .../manage_players_widget/view_info.html | 155 +++ .../manage_players_widget/view_options.html | 27 + .../templates/webmap/player_actions.html | 168 +++ .../templates/webmap/player_markers.html | 3 + .../templates/webmap/player_popup.html | 64 + .../webmap/player_update_handler.html | 58 + .../players/triggers/admins_updated.py | 36 + .../triggers/discord_webhook/__init__.py | 4 + .../triggers/discord_webhook/webhook.py | 284 +++++ .../players/triggers/entered_telnet.py | 115 ++ bot/modules/players/triggers/left_telnet.py | 78 ++ .../players/triggers/player_update_on_map.py | 108 ++ bot/modules/players/triggers/playerspawn.py | 138 +++ .../players/widgets/manage_players_widget.py | 628 ++++++++++ bot/modules/storage/__init__.py | 60 + bot/modules/storage/persistent_dict.py | 109 ++ bot/modules/telnet/__init__.py | 466 ++++++++ .../actions/toggle_telnet_widget_view.py | 45 + .../telnet/templates/jinja2_macros.html | 56 + .../control_switch_options_view.html | 9 + .../control_switch_view.html | 3 + .../telnet_log_widget/control_view_menu.html | 10 + .../templates/telnet_log_widget/log_line.html | 1 + .../telnet_log_widget/table_footer.html | 3 + .../telnet_log_widget/table_header.html | 3 + .../telnet_log_widget/view_frontend.html | 27 + .../telnet_log_widget/view_options.html | 27 + .../telnet_log_widget/view_test.html | 22 + bot/modules/telnet/triggers/server_time.py | 36 + .../telnet/widgets/telnet_log_widget.py | 233 ++++ bot/modules/webserver/__init__.py | 600 ++++++++++ .../webserver/actions/logged_in_users.py | 42 + .../toggle_webserver_status_widget_view.py | 45 + bot/modules/webserver/static/favicon.ico | Bin 0 -> 15086 bytes .../webserver/static/jquery-3.4.1.min.js | 2 + .../webserver/static/lcars/000-style.css | 5 + .../webserver/static/lcars/100-variables.css | 81 ++ .../webserver/static/lcars/110-fonts.css | 9 + .../webserver/static/lcars/200-framework.css | 127 ++ .../static/lcars/210-scrollbar_hacks.css | 15 + .../static/lcars/220-screen_adjustments.css | 19 + .../webserver/static/lcars/300-header.css | 25 + .../static/lcars/310-header_widgets.css | 69 ++ .../webserver/static/lcars/400-main.css | 12 + .../static/lcars/410-main_widgets.css | 466 ++++++++ ...1-main_widgets_webserver_status_widget.css | 0 .../412-main_widgets_telnet_log_widget.css | 30 + ...413-main_widgets_manage_players_widget.css | 92 ++ ...4-main_widgets_manage_locations_widget.css | 52 + ...15-main_widgets_manage_entities_widget.css | 8 + .../webserver/static/lcars/500-footer.css | 6 + .../static/lcars/510-footer_widgets.css | 0 .../webserver/static/lcars/audio/alarm01.mp3 | Bin 0 -> 23073 bytes .../webserver/static/lcars/audio/alarm03.mp3 | Bin 0 -> 16929 bytes .../webserver/static/lcars/audio/alert12.mp3 | Bin 0 -> 9249 bytes .../static/lcars/audio/computer_error.mp3 | Bin 0 -> 19270 bytes .../static/lcars/audio/computer_work_beep.mp3 | Bin 0 -> 15508 bytes .../static/lcars/audio/computerbeep_11.mp3 | Bin 0 -> 5409 bytes .../static/lcars/audio/computerbeep_38.mp3 | Bin 0 -> 3873 bytes .../static/lcars/audio/computerbeep_65.mp3 | Bin 0 -> 10785 bytes .../static/lcars/audio/input_ok_2_clean.mp3 | Bin 0 -> 28449 bytes .../webserver/static/lcars/audio/keyok1.mp3 | Bin 0 -> 3105 bytes .../static/lcars/audio/processing.mp3 | Bin 0 -> 48417 bytes .../static/lcars/audio/scrscroll3.mp3 | Bin 0 -> 36129 bytes .../lcars/fonts/SWISS911ExtraCompressedBT.ttf | Bin 0 -> 38892 bytes .../lcars/fonts/SWISS911UltraCompressedBT.ttf | Bin 0 -> 38860 bytes .../static/lcars/ui/main_horizontal_bar.png | Bin 0 -> 178 bytes .../lcars/ui/main_horizontal_bar_end.png | Bin 0 -> 762 bytes .../static/lcars/ui/main_shoulder.png | Bin 0 -> 1897 bytes bot/modules/webserver/static/leaflet.css | 661 +++++++++++ bot/modules/webserver/static/leaflet.js | 6 + bot/modules/webserver/static/reset.css | 49 + .../webserver/static/socket.io-2.3.0.slim.js | 9 + bot/modules/webserver/static/style.css | 19 + bot/modules/webserver/static/system.js | 528 +++++++++ .../webserver/templates/frontpage/footer.html | 1 + .../webserver/templates/frontpage/header.html | 14 + .../webserver/templates/frontpage/index.html | 36 + .../webserver/templates/jinja2_macros.html | 20 + .../component_logged_in_users.html | 9 + .../control_servertime.html | 5 + .../control_switch_options_view.html | 9 + .../control_switch_view.html | 4 + .../view_frontend.html | 28 + .../webserver_status_widget/view_options.html | 27 + bot/modules/webserver/user.py | 30 + .../widgets/webserver_status_widget.py | 190 +++ bot/options/README.md | 42 + bot/resources/GUIDELINES_DEPLOYMENT.md | 673 +++++++++++ bot/resources/GUIDELINES_LOGGING.md | 106 ++ .../GUIDELINES_MODULE_WIDGET_MENU.md | 211 ++++ bot/resources/configs/.env.example | 74 ++ bot/resources/configs/chrani-bot-tng.service | 51 + bot/resources/configs/gunicorn.conf.py | 146 +++ bot/resources/configs/nginx.conf.example | 156 +++ .../services/analyze_callback_usage.py | 345 ++++++ bot/resources/services/debug_telnet.py | 71 ++ bot/resources/services/test_all_commands.py | 179 +++ gunicorn.conf.py | 162 +++ requirements.txt | 20 + wsgi.py | 32 + 240 files changed, 20033 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .gitignore create mode 100644 .junie/guidelines.md create mode 100644 .run/app.run.xml create mode 100644 .run/gunicorn-dev.run.xml create mode 100644 .run/gunicorn.run.xml create mode 100644 DOM_HELPER_MIGRATION.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 bot/__init__.py create mode 100644 bot/constants.py create mode 100644 bot/git.txt create mode 100644 bot/logger.py create mode 100644 bot/mixins/__init__.py create mode 100644 bot/mixins/action.py create mode 100644 bot/mixins/template.py create mode 100644 bot/mixins/trigger.py create mode 100644 bot/mixins/widget.py create mode 100644 bot/module.py create mode 100644 bot/modules/__init__.py create mode 100644 bot/modules/dom/__init__.py create mode 100644 bot/modules/dom/callback_dict.py create mode 100644 bot/modules/dom_management/__init__.py create mode 100644 bot/modules/dom_management/actions/delete.py create mode 100644 bot/modules/dom_management/actions/select.py create mode 100644 bot/modules/dom_management/templates/control_action_delete_button.html create mode 100644 bot/modules/dom_management/templates/control_select_link.html create mode 100644 bot/modules/dom_management/templates/jinja2_macros.html create mode 100644 bot/modules/dom_management/templates/modal_confirm_delete.html create mode 100644 bot/modules/dom_management/templates/modal_confirm_delete_cancel_button.html create mode 100644 bot/modules/dom_management/templates/modal_confirm_delete_confirm_button.html create mode 100644 bot/modules/game_environment/__init__.py create mode 100644 bot/modules/game_environment/actions/cancel_shutdown.py create mode 100644 bot/modules/game_environment/actions/force_shutdown.py create mode 100644 bot/modules/game_environment/actions/getentities.py create mode 100644 bot/modules/game_environment/actions/getgameprefs.py create mode 100644 bot/modules/game_environment/actions/getgamestats.py create mode 100644 bot/modules/game_environment/actions/gettime.py create mode 100644 bot/modules/game_environment/actions/manage_entities.py create mode 100644 bot/modules/game_environment/actions/say_to_all.py create mode 100644 bot/modules/game_environment/actions/schedule_shutdown.py create mode 100644 bot/modules/game_environment/actions/shutdown.py create mode 100644 bot/modules/game_environment/actions/toggle_entities_widget_view.py create mode 100644 bot/modules/game_environment/actions/update_bloodmoon_date.py create mode 100644 bot/modules/game_environment/commands/when_is_hordenight.py create mode 100644 bot/modules/game_environment/templates/gameserver_status_widget/view_frontend.html create mode 100644 bot/modules/game_environment/templates/gametime_widget/view_frontend.html create mode 100644 bot/modules/game_environment/templates/jinja2_macros.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/control_switch_options_view.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/control_switch_view.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/table_footer.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/table_header.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/table_row.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/view_frontend.html create mode 100644 bot/modules/game_environment/templates/manage_entities_widget/view_options.html create mode 100644 bot/modules/game_environment/triggers/new_bloodmoon.py create mode 100644 bot/modules/game_environment/triggers/shutdown_handler.py create mode 100644 bot/modules/game_environment/widgets/gameserver_status_widget.py create mode 100644 bot/modules/game_environment/widgets/gettime_widget.py create mode 100644 bot/modules/game_environment/widgets/location_change_announcer_widget.py create mode 100644 bot/modules/game_environment/widgets/manage_entities_widget.py create mode 100644 bot/modules/locations/__init__.py create mode 100644 bot/modules/locations/actions/bc-export.py create mode 100644 bot/modules/locations/actions/bc-import.py create mode 100644 bot/modules/locations/actions/edit_location.py create mode 100644 bot/modules/locations/actions/onslaught.py create mode 100644 bot/modules/locations/actions/teleport_to_coordinates.py create mode 100644 bot/modules/locations/actions/toggle_enabled_flag.py create mode 100644 bot/modules/locations/actions/toggle_locations_widget_view.py create mode 100644 bot/modules/locations/commands/add_location.py create mode 100644 bot/modules/locations/commands/export_location.py create mode 100644 bot/modules/locations/commands/import_location.py create mode 100644 bot/modules/locations/commands/send_me_home.py create mode 100644 bot/modules/locations/commands/send_me_to_location.py create mode 100644 bot/modules/locations/commands/start_onslaught.py create mode 100644 bot/modules/locations/commands/take_me_to_my_grave.py create mode 100644 bot/modules/locations/commands/where_am_i.py create mode 100644 bot/modules/locations/templates/jinja2_macros.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_edit_link.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_enabled_link.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_player_location.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_switch_create_new_view.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_switch_map_view.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_switch_options_view.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_switch_special_locations_view.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_switch_view.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/control_view_menu.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/table_footer.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/table_header.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/table_row.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_control_send_data.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_identifier.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_select_shape.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_select_type.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_set_coordinates.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_set_dimensions.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_set_name.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_create_new_set_teleport_coordinates.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_frontend.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_map.html create mode 100644 bot/modules/locations/templates/manage_locations_widget/view_options.html create mode 100644 bot/modules/locations/templates/webmap/location_actions.html create mode 100644 bot/modules/locations/templates/webmap/location_shapes.html create mode 100644 bot/modules/locations/templates/webmap/location_update_handler.html create mode 100644 bot/modules/locations/triggers/location_update_on_map.py create mode 100644 bot/modules/locations/triggers/player_died.py create mode 100644 bot/modules/locations/triggers/playerspawn.py create mode 100644 bot/modules/locations/triggers/zombiespawn.py create mode 100644 bot/modules/locations/widgets/manage_locations_widget.py create mode 100644 bot/modules/permissions/__init__.py create mode 100644 bot/modules/permissions/actions/set_authentication.py create mode 100644 bot/modules/permissions/actions/set_player_mute.py create mode 100644 bot/modules/permissions/commands/password.py create mode 100644 bot/modules/permissions/triggers/entered_telnet.py create mode 100644 bot/modules/permissions/triggers/player_authentication_change.py create mode 100644 bot/modules/permissions/triggers/player_moved.py create mode 100644 bot/modules/players/__init__.py create mode 100644 bot/modules/players/actions/getadmins.py create mode 100644 bot/modules/players/actions/getplayers.py create mode 100644 bot/modules/players/actions/kick_player.py create mode 100644 bot/modules/players/actions/say_to_player.py create mode 100644 bot/modules/players/actions/teleport_player.py create mode 100644 bot/modules/players/actions/toggle_player_authentication.py create mode 100644 bot/modules/players/actions/toggle_player_mute.py create mode 100644 bot/modules/players/actions/toggle_players_widget_view.py create mode 100644 bot/modules/players/actions/update_player_permission_level.py create mode 100644 bot/modules/players/templates/jinja2_macros.html create mode 100644 bot/modules/players/templates/manage_players_widget/control_info_link.html create mode 100644 bot/modules/players/templates/manage_players_widget/control_kick_link.html create mode 100644 bot/modules/players/templates/manage_players_widget/control_switch_options_view.html create mode 100644 bot/modules/players/templates/manage_players_widget/control_switch_view.html create mode 100644 bot/modules/players/templates/manage_players_widget/modal_confirm_kick.html create mode 100644 bot/modules/players/templates/manage_players_widget/modal_confirm_kick_send_data.html create mode 100644 bot/modules/players/templates/manage_players_widget/table_footer.html create mode 100644 bot/modules/players/templates/manage_players_widget/table_header.html create mode 100644 bot/modules/players/templates/manage_players_widget/table_row.html create mode 100644 bot/modules/players/templates/manage_players_widget/view_frontend.html create mode 100644 bot/modules/players/templates/manage_players_widget/view_info.html create mode 100644 bot/modules/players/templates/manage_players_widget/view_options.html create mode 100644 bot/modules/players/templates/webmap/player_actions.html create mode 100644 bot/modules/players/templates/webmap/player_markers.html create mode 100644 bot/modules/players/templates/webmap/player_popup.html create mode 100644 bot/modules/players/templates/webmap/player_update_handler.html create mode 100644 bot/modules/players/triggers/admins_updated.py create mode 100644 bot/modules/players/triggers/discord_webhook/__init__.py create mode 100644 bot/modules/players/triggers/discord_webhook/webhook.py create mode 100644 bot/modules/players/triggers/entered_telnet.py create mode 100644 bot/modules/players/triggers/left_telnet.py create mode 100644 bot/modules/players/triggers/player_update_on_map.py create mode 100644 bot/modules/players/triggers/playerspawn.py create mode 100644 bot/modules/players/widgets/manage_players_widget.py create mode 100644 bot/modules/storage/__init__.py create mode 100644 bot/modules/storage/persistent_dict.py create mode 100644 bot/modules/telnet/__init__.py create mode 100644 bot/modules/telnet/actions/toggle_telnet_widget_view.py create mode 100644 bot/modules/telnet/templates/jinja2_macros.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/control_switch_options_view.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/control_switch_view.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/control_view_menu.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/log_line.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/table_footer.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/table_header.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/view_frontend.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/view_options.html create mode 100644 bot/modules/telnet/templates/telnet_log_widget/view_test.html create mode 100644 bot/modules/telnet/triggers/server_time.py create mode 100644 bot/modules/telnet/widgets/telnet_log_widget.py create mode 100644 bot/modules/webserver/__init__.py create mode 100644 bot/modules/webserver/actions/logged_in_users.py create mode 100644 bot/modules/webserver/actions/toggle_webserver_status_widget_view.py create mode 100644 bot/modules/webserver/static/favicon.ico create mode 100644 bot/modules/webserver/static/jquery-3.4.1.min.js create mode 100644 bot/modules/webserver/static/lcars/000-style.css create mode 100644 bot/modules/webserver/static/lcars/100-variables.css create mode 100644 bot/modules/webserver/static/lcars/110-fonts.css create mode 100644 bot/modules/webserver/static/lcars/200-framework.css create mode 100644 bot/modules/webserver/static/lcars/210-scrollbar_hacks.css create mode 100644 bot/modules/webserver/static/lcars/220-screen_adjustments.css create mode 100644 bot/modules/webserver/static/lcars/300-header.css create mode 100644 bot/modules/webserver/static/lcars/310-header_widgets.css create mode 100644 bot/modules/webserver/static/lcars/400-main.css create mode 100644 bot/modules/webserver/static/lcars/410-main_widgets.css create mode 100644 bot/modules/webserver/static/lcars/411-main_widgets_webserver_status_widget.css create mode 100644 bot/modules/webserver/static/lcars/412-main_widgets_telnet_log_widget.css create mode 100644 bot/modules/webserver/static/lcars/413-main_widgets_manage_players_widget.css create mode 100644 bot/modules/webserver/static/lcars/414-main_widgets_manage_locations_widget.css create mode 100644 bot/modules/webserver/static/lcars/415-main_widgets_manage_entities_widget.css create mode 100644 bot/modules/webserver/static/lcars/500-footer.css create mode 100644 bot/modules/webserver/static/lcars/510-footer_widgets.css create mode 100644 bot/modules/webserver/static/lcars/audio/alarm01.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/alarm03.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/alert12.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/computer_error.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/computer_work_beep.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/computerbeep_11.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/computerbeep_38.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/computerbeep_65.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/input_ok_2_clean.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/keyok1.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/processing.mp3 create mode 100644 bot/modules/webserver/static/lcars/audio/scrscroll3.mp3 create mode 100644 bot/modules/webserver/static/lcars/fonts/SWISS911ExtraCompressedBT.ttf create mode 100644 bot/modules/webserver/static/lcars/fonts/SWISS911UltraCompressedBT.ttf create mode 100644 bot/modules/webserver/static/lcars/ui/main_horizontal_bar.png create mode 100644 bot/modules/webserver/static/lcars/ui/main_horizontal_bar_end.png create mode 100644 bot/modules/webserver/static/lcars/ui/main_shoulder.png create mode 100644 bot/modules/webserver/static/leaflet.css create mode 100644 bot/modules/webserver/static/leaflet.js create mode 100644 bot/modules/webserver/static/reset.css create mode 100644 bot/modules/webserver/static/socket.io-2.3.0.slim.js create mode 100644 bot/modules/webserver/static/style.css create mode 100644 bot/modules/webserver/static/system.js create mode 100644 bot/modules/webserver/templates/frontpage/footer.html create mode 100644 bot/modules/webserver/templates/frontpage/header.html create mode 100644 bot/modules/webserver/templates/frontpage/index.html create mode 100644 bot/modules/webserver/templates/jinja2_macros.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/component_logged_in_users.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/control_servertime.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/control_switch_options_view.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/control_switch_view.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/view_frontend.html create mode 100644 bot/modules/webserver/templates/webserver_status_widget/view_options.html create mode 100644 bot/modules/webserver/user.py create mode 100644 bot/modules/webserver/widgets/webserver_status_widget.py create mode 100644 bot/options/README.md create mode 100644 bot/resources/GUIDELINES_DEPLOYMENT.md create mode 100644 bot/resources/GUIDELINES_LOGGING.md create mode 100644 bot/resources/GUIDELINES_MODULE_WIDGET_MENU.md create mode 100644 bot/resources/configs/.env.example create mode 100644 bot/resources/configs/chrani-bot-tng.service create mode 100644 bot/resources/configs/gunicorn.conf.py create mode 100644 bot/resources/configs/nginx.conf.example create mode 100755 bot/resources/services/analyze_callback_usage.py create mode 100644 bot/resources/services/debug_telnet.py create mode 100644 bot/resources/services/test_all_commands.py create mode 100644 gunicorn.conf.py create mode 100644 requirements.txt create mode 100644 wsgi.py 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 0000000000000000000000000000000000000000..cb222e463c7cc4cc1520858a4b3d9b533b47df9a GIT binary patch literal 15086 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2b686e11b56340cbff29b2a627f1cf799906b941 GIT binary patch literal 23073 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..36e4cb4f8e54587348feaaa10b68780e6e5077cc GIT binary patch literal 16929 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 M|JP6Yzm35E0d=}eI{*Lx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f5c4b45f7d4ae5a8ace141ca7d012719f2e4c27e GIT binary patch literal 9249 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Φ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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6dca5503a34dced316033e94276cb472e3211984 GIT binary patch literal 15508 zcmeIZXHZjJ81K7N2mu0wo=`(50#ZU15H&#P#n6{*P?3aQr3wKQsnP{0(q1K0 z0YQq0_}UNwX^IVf1z{gJ=gawYX3m|tbMFVAnM~H+v-f1J|8K2l{ny&rCL~oDa2R5d zw)$r+4-4?aj|@SUY;AJxu*3lXWKY*i=Un|GJOOd!;nDv(&jkRwk)ENh-k#>3k)Hm* z)uY?~#$N^gD)3i$%}Cm5${u`UntoHznMZ};F2Lle_^`Eodk zMAEx_Iqdb@V<7nASG8=jiQU#N^;@Iwk5h)tWSwYkXsa(?S8HZJ^L8ET-0^2$7qC*X zecLHq;tlWqonReO&b7OGivE22SIM8z=B(SZ`FHEg-~ETW8EyXgPG{R_lJ?o!7p&>` z%h}_MrCD>|DlFC@e%A|oynY}*rF_N=-SI3GxR#tZJS(r#zeY7KW6zX_g>c6$q^I0A zy}1+Hyfyu@qRGzA&X>JJv#}w^<#$z}bF;0xgI;c)?}vJ`^x+}rp_r2+HB%Lh$6r-W z@-s)>@h&|;U4O80T&LM>xHJ8n%)bhP5 zQGFXA>4V7?i3*4ftFFpt`IDN-Qa_6JAAFD97=JXs=Jpi9*1fINeKptrt~9ut%kc9P zhS)!>_-UEWr7v>gqU($o!T)_`@0&uhX>d)XoApbN>Q{A2^qWtrt?6C`uTP3}j8^WQ zwN^GdCHcD8cH3Plqx6aFO|u4l<^Qx{|0#M6#qC@QSzA7LuoCtv(_6~`KmO_I=O)hR zy71QKk-P39-s&raImCO0A{+awgBd#>UjE(X>$Rm+AIFVC?IwTmv`-~GlJb<9PVBS$ zyvqDT2@ohasJX&~SIuHFgforsCQD0~uFiGT#Bh>^FkfVeb^JPd?eWH_wN5oyoL)H0x`g&RQtv zQuC>yMT)QH*O@d%h-OYJ-;UPqOA{+N~0Hx%-P@?89k8pMZ=6D zv{s3Vx9g;fxpEnU+f#3*@HT1Z0vbp}5Q^$2&Ye`dkn*Jt9ijTKUVwyiMvsBJoew<% zaK~IHv1zw%q7!q6=Cu;^zuv^j=E23wuKX@aaG+9D=#O3mJ^lCN{y&H9DVpe7^gkkW zj9-w(uiOiLn)o&DM<)e4bDHp;z?D}MW)IND#D{Te76_I?Xb*hJin0C z(p#+{==MQXY3@gpYRM(dMtauL{FJL%uj83FVGSSOc8_eP9PHe@{pUnf!hxg%mUnGc zH8I9`BC0+s!AkHPLA+woP9TIuA$4}w1y$hPI3yzDnNVhiW>laeIX!{t)!-qjGvf;j z)A?5Cc6As<8c0tvyphB!KN1)2&7HW)4jnEeBSc@kOT8`zzUkaP9Kk9@DW4n5I7=0O zNzyw`=@&U4SDc$K;4TnHS zzvogSOU$Qm+pFvm>zTSY?>YKCu79|v=)iwGT&Sl21~pR1 zJ)(Txb*>UUL_aSoKN`CDFCtZwxd)RCTNVzb+}R?1h{MSqv$HqG^D(oFD{|QD-sol; zO_}w2rd;^wTYAp{X}!+1pxs@DBZO7}sF!kkO!5Iqx$%0Q8P9UOglIA{dTGL@a^pvY zej#My1;17TmMfp)1N)csx(UGU)S1H``5nw&zJ&4SZa)J{U<#v(uSC~ibCs{DV1&J^ ze-&{Kpg;Ja-`r-}H_x}JofE8bC^z33*?s%yog(9r->&YPAoWvwe3J1yI-+~zpld_{ ztKsj>cj{c1e&oLLi&wjRBAu(IXiFk9F%ELvv{CV$Ms5CtXV)8dxn&p4ykCR5+8^Vw zq9}kDg-{<&|M}@vI4`OLfw@o=N8-B31iYWV=x#@ zfT={sk|&kNeXRR5Sy=;E$qC(!1)rHeqK`9mW0h+ig{hrif)XrXhF#-a;rkmBlP?HZ z;*f`VDIOu&Eg#3~^x)umo@AWp?dLnko3C%40>KBPJtoKL_#mqvmL;M#STTjh`U}S_ z7N(Q&mSxf7NfTaV^Mt$)#g;d7q}Wq>m?R`rg6}#>vy?AKqQk5*gfje6?+;eubTXH| z-X}30>9cxI*>SJR+xSBR>0eB%r>Dyx{R9>%36#unEvyJz=75J1wjDl1oRG6|KO*#a zl@}1KF5<^dP+@}2s}iaVfcFiiafvvEvK-LVL81Jd?_`?f7E8ogq-c4zG08L1Q^rZa z1+Wmmzx_~2%KBJFI=Hx4%W^sFyx=t14ifS%@&cVk%C(dBiZ(j<*imakcy88YGU$=< z@LMWT{tBz*m6khy&M5Xy{gH_2+by>&%rrc<`t^6JMn@m`BJfX1XUD`2=-!&@+0zG3 zNYXEXvHwIg2FzCsT-^iTIz9Zk6Iy+R4jzD_2Q^x^EvbO0I28)W->%5|lojw#DO;22 zP?RD&?&VDAs)KooK68+S+ARzdCfh_)hX1l(MN5ks%2pZoWgzamLWUI!HbaHTo2}pc zJBVGh8p#BW=N8K<&ey)H!kK|VB`iI==KFDA@J5qdoovO86QPuggogne((o5P0?sGx z>W4qrKKFZnUA_L_hQu4QiYA2xN?m;$evx+82e$8imd9pFJ#GUXeq98?cTZy8f#ApA z-m#n@xP5u*a0DEl{QT?S_fg^Ag>nc?l}X{uzs@RmeFFk9*c7(4g{H{kzmEvzJ!7L5 z2{KEbDgjvksJpLB=`G6SN}rXpxJgfnL&Fg)w3Kec(aEfV5^-i^Rs8&MDiI5VQ~yCp z@wBMr9|Hh9gto{kFbAU|sq*I{9+~s84Ykgwd*+)|3u6aWDHzuN&n5VH2e6=2YP$${hcd%dF!0abRu!f!}5&E^#v30IV?$#6L z;X3`OZ%y_F6UR(BD!!EJ-5;8^koX-uX;K2{oqY9FQ0!k*)V4}2ICpJ5Y))#Z@1Qg2 zyiMO+oRdvc_~+_dr*k6UR3chcN}yNltwxxB+{Z{{0!r3jq;$h<>xj^pJv%_RC=_Ae zEo4*e_i*bZL%eTQ7=b01Z+D1ZI_4iI&+4g<@Q1r47y@b(s3EtC6F~-sfdJyvX%;#k zM3cZd0Zk#!E;>%|#2a(78Zs~HNJ3zY?3c~4;C@t7ukHCx@Mc4JdOt%E8(*seqq;fh z8c5ww{50)3afjceK+|?J)$5*@Vw?DFR%VNhXV$q90zf@9Cw5M2d3ni{z<=!VHn=-> zCtIa0cHXTI^!yoq$qAQpy59q0j7IMmm;4G;x}$p6fl~+_HUGmDrxV}uk6AZrSx07QU7MnfP(djtfSCQP@ZGTdRZ+>GgwY-L?x$W5GQoD2lT z$f@FHN$i%ZD5T@6W=wWkr43Xl+KaBZQ8Iq?CdzQ;^QMD`f5X_%NW(V#7}S5vMZ+=m zeQ2&^p*0+yZE=!zP(l19OCw#hO~aEf~c72CCj zpm4Du`I&`k7l>p@4*ub6in?nH7Ysh2X;bRJD{&zwmGr1?<_Vhwy5*&F+$pv2 zxwm=s+fL_FU*Gka%Qi9e!CyFB$?zLO0k~EI0K-2!eyywMpYJ%dd<;$80j8?z;Wjd% zdR7Z2j?iCA&vadTZHbd4A^f-s4&y^7zOHve{;#$Nk*_-Dq zz65-xeFQQ0*n5nhFw~Zq;0CsAWu9TUAf~o3NjY8s$0Ydm0lTi0sK#M`g#;0_wZ>5K zTvCL$Ay2}2X##Fm2IBW>%wy6sc9{+H)-sN1TJgg}ZsCvxZ0EsK4STVhehT~NoGc@! zx_$|}qR~_!0k!0z5zzT0x7Ihz$Q8X_u5v`^6P*pf{A3Y7RZnH((5RG}9BYYWF59Gc zNG2^0m?&_2P;cs=WD7)~SeO9!QhkFFVfxhy9U%#G3o0B1*QFwbX}NDH(Yp2+Og4f8 z3CWEwBgu(i1&A-bNxXUvK|j)mKP&~p#mOtiY0`tAPS$44y9g-f%FKz&DXfbLN;mDB zNO}pIAk8G^JTCo+=>tK!(szxGpo7jAJpmghjCyjq8^jFc>LfcVIur~)@;K&3R;3r; z(TFtRyGv=e@LDgy)KbX+z{;RN0*=KVf6<`OLC;^t=RpP-dM~1Tm<%U07rLhuL`vWL zxjs~?gMa<^$BRctrZ>%@hliD`nEmTv2{B8}*!bjtU(juHxSR z_!TQ=YG0-+o;Vh|-gdp6jI}CYvpvpmV7qT1jfj^IN2p2icxG$VkP|ca{gsT^-nd-J z6A|C$PwcR`tHfoZc3a9%xA4$z9+&!U@cYi^4eKitc9BhHj`?n<$X4-1wU1JZ%-_t) z%fSIET&3LSOz|7~pWtxPC_J6uPCFu`)WAwDd{~KP|Ct}ptL9ezXk;N?#H*C`9?$E3 z634=@`-zSdP5tO)TS0600qL|r&w<5hLX;c_G!w)1(l-(zPgn}#4$Xukuh1z{p^Hkh0&xSl;Yq{iOUvSQ9H{TTCUqzoF* zRZWT-ylMt@n_5feCy9Z#5z8Dy7o7w7_k1fmet}^Ac;B?bk5h$jY9p<)drCi=b7@9Q zRRNF$X2cHId;jA#L(`B=k0HYg3Bo-wlsZDPiUgS6xiShI+yHd|2Vs|D{hY#B?-k%6Ji7&I4gnF#Q_|m0XB{T!& z=MNnncyQiV^*%a8j)P;v+gB@KH>G7HNm(uAis+L6XjU0%c9(J!eLVIfJM}dW`?VOp z%}Lt4Hu^HPn;f(3w&8Pf|aWFTfGx3U=|jo!fm1G*7r z-H+#28rLsJ7xC|t)-Qv#Rk|iGejpzoHWUOW{+T)0H+xXn%4(gh>r*^^#%adIL@P~t ztcN@G1GCPxmWpqlcEVO43oMJz*1oS#P<40-I3Be zt;EBFFU7fs4;g+mOuJ>6J6bpBOC}JxDOtSly-{DMA1o@eh?G&RMzQEx4XRo(q(2v6 z(q*7*aYQvXIG!SyO#2{4d&zzak-(Cy0Yfl(~S7^Cm?I9wBI$ zWEtc2Apr2Gx!>R$L{7Cz&^H%t3YbX@Z z#Arikt|Ufy6^&O}39W2~s9*wESP=lE43R-mTa#c6tr6h5TPQW?;(hX*@$tf3r>4Fg z-sD*~Z3yv!pviLjK;AJc6UEk)t+jju+FqTwq6oNe2q{c@axLHN2%J>xD{yb~qiuKY1v`PT#g3w z5v8a7R&32JC8U7EoD7S#jN#y$jG*@pa+`h7X2UV7%vX#9{HW^Wy6XvTDyr zSx1CaZ}9^{bwz^Ry#REx-#xyA3`iT~YEf9>L}L~#MFu)RmA4w8=%5s7Zi_BB6oi<< znH-E_i_0dAOA&|Zryw{SGqgonSb_0Gg~VGHH#k5hc(D`v?OE{t4EK!dG};$9DPX|} zp0t7{2m&T9=p|~4vk<9BCfDE87?9zv5j~FEpiJufv9`n$V zQ!{584%R_W$GgAxjq5I1BRY6Ka?bV6wt6K=GsW-DSFHwy`0{6PFFqn(nHOT|K%70B zMrD#!{$_ciZHZ!g{CstffBBpMDNh2vXn8*c4NVw@)h>`Fsc480H@ELIbw$mx8`H7NNUo8h~-GVg##+hdyYZ?d!svl9={$KN?-Tb4m zZAJJ%18#zW5w~txZBeiwtEZj!LsPQuGZ;n+32`7z7yf#o}qaXxEuh0dZ}NjzS9)tY4oZ z=+eauaf7;**^eLuIRV1!1Vt`R@XyekLM;YhRY*}uZ+&rR1vQcqX_y-k!TXn^7 zk%iP3GA9+Z&2zfDQHKp=IZWdtZ|Q8WBFyzie$PO=V)%~;^@|^Rx#vZ=q6PrE(OSi$ zPKJ>J{7TL%TJ4bPrrt|e2%BI8795yt*%s(6_2og2;b_N8}5PWFv*|yBKdk)1IdrhdgzN z-9Nq}tp_;f75w$G|C zThhl^oo}JMJXV6`PV%Iz!61-+qLzj%tpuAB3L~cotxcGww?Nrxje9WdOscHIV5O?%d@j|+q2sgpu#~NUT`=4IUGl7$eohia}j{`6cRY};PQz)KVPY7rw zQ2Zlrv zhOdVD!p6|4Z=>I?-IPfleRAXegmI?A)~APl&Q*!c1+8a5?K~;V;SLOccczaMTLl*aL9$U;~3g z?d%V5kxuDh%Z$1T+AQ)pD{$_Cy!w2-=)}{AdvW{(mEh#? zg(UvE3pNUc$jR4uTuQch@W@=r(HMGm3k6{LibS!!08Fq=!byt=6ivXh0Kw?Js+EMK z`}9;4z|Mw~VbWhiz#y9Pz+en84Tn>+$C9>C0FIljiDE?on3|#Z{HDu9t*=936#lr(Cn>q#0H(cV6YsOW)AhU$~rneu7*f&#*(L^@Frj!OxUAQ%C0IKQ}bFH4k^ z=d?WFaoU=L=+sTpmf#qeR!ACHo1;q4EawTqH4LV52!Ccw+~50&|FhmJP$jrQipLv` z-gv5c&d0xdYPI;)4?lhILc7SzEHV6xsvkC1xzp}=)`(5NOCAvaAN08o!35&#`j zXK&Z6v0G3T+b2ha20yS+F{v?9hkFXpN4k6Dl>{&@_&M4>N+1o#+81VR`>yJf$4sr4}3K!4Cu*~$V|QcB^{P;m+& ziOf#%LiAVvgZgul9dm~+8bSva{A)fs8+`TnXud1E zzFUZjW9!)h!;%6nVc{-$+jKZ{rtU(<0XVe=#$0^5Gq+b>da`cydhx9JX0=2T3lac` zf(l#eaht4{o?pNj>$w^lV3UQppIdlno%3o<{1R!Nl@ZLA2n?2e8996&06`yvA=B*B z*o>PD03?*C6O53^hm;5}QYy;O2uR8ZR5CTSNeSFMDM=h4Dj!oWd^B+YuqJ6+VEUn?>}{DXTh%U!VTaMm!| z^*OT<;Mnog#4-1yjZ?X0ai(J0eC|slZsj}1V@7jv-kD@CW$8?w+hNr(%A*^nd;Y#ma^KlXFSt6MGiDvuXaV=4fy)Kg;2Cs-=_ur_m*ATyO|eWBPV* z_=r%^8Y_e!&m=BVPh}nG_h3DA+uCd)XBMf968{zH=j|Cz(tb60Vr= z6-8uSa5Iwnkyv{->mb7>?cHs~&ck~D+5Vmm?!W9+P832j|>z?Ae1%2QpJ5o z4%hy~bR+pQlD7+tgnTMq&g$+ER^Kh;p9c5Ee{&veRXPh_?upYY$4LEN21A^8x*#x z0hbOSKe3j*^k;-C+}^qxRGY}c1Em*1uuMY}l#f2lGgwbE zr_I>QZ!f@dSel9ZhEit5BSPR(cJUvC4?aCh)~{pUMQb>13!nA zkY2BDm174Hpd;mV1T479;=a3n9$4|^)aT{4*gF&z@?7 zmV@A#EDVCqgR4^F-cVQS%sr-5XNKNmG2Xo8;MuFT3TFoYk*|EWJC>t;legzCjQVcA z`DGutZ*Xzik6w2P9qYPTUzdkryXL>PtyVt0RFFWT0D2o>08~YF+Ed+ z_M-0D^eRm~^v${-ctSdfKqf9QPEd`ZXxD0%=t69M23 z6Ob|JE(sfU59fIWNRl1%1nsL z@IdghU`A7X8hx)N0G06Ml%ywX@_1!}$ApLK8E*5t3dAEqL(K|+hkcLE~67bE>*-4+qw^v}LoYkU2j%Eo#AGC;-!**<)@Vf)$O zr1ddJr?_kCJ7ssXuZ9Px1oXbk=-6($eCG4{_itUg(%5DQuOmkif35JmJK8SU_k6nD z2ToO97Lyfw8vFgu{-1B3qqd6&Z};u~x&H2C^1;=Wv|oKrPgk*-IFNnmwyc!1`U?S^2wF&trez*k@{>L~TeQvj2aGBI z2<~t7zIy>Gz6IaBQ_cYZ1~4J===*~Uzua8-=`l$jGSx?f{Ipr2H{UWJKb1y>ws&|i zHCty7PV_y^=CQR6ydcZ-p;m|Jku7uEBrfVDe%D~;1{P07!x_W+XyxaP9HcS;kArdP zJ`fLss;jE0$X7RR;?qPvn#Q-!xf6+H=LWl8ZjFrby^~HpWi076!iT+ADXx#=aeU~K zUOvOc%WWnrbW;XT{f%{t#;-R-NFDU;N;N;<>Ub3k#?Ies-fW89c533B8@KC~&MbU5 z!9{{y*f&o*kI&Io$;at?A>s&n=*~W{_v>D)$oZCu{VJezYZ&5vM-KV=)JgfC89gdD z3aDurKx}cPM{xOk~yo!Soo(>{z3#3-hVgO=4PV5Woi`mp+s@+M3zKE9wKR6 zm4n`?;cgS5Fs1IA2ZPA z$vc&iOck2PU9}WVu54jR83qH*ji#F-=_DmAAOSfdbi+YB-cI%oP9ZYVw63$#;CAVP zS(x^)dwFRX9tOh>g}Wo5!xS5Y-4xHmH(m;rbtY9-yezzsaM~v0#!5B*rprr1Puol0 z9>?e06e&9vB^!1|CX66t-HEyxr&Ic=O25E~GdF7X6@zDT3zas1HAUF=yqO{OBS_(i zMo(W8M$;$TE9X70;Z4HNu1tZ?3<}4K^yBs<+W}9io&R!pds6+-Fp8f9iM8#Auj-`v z8LXZz^L@X%)#}qWqSh5aLD-+BHDw#3o^MZVf|u_PcJ%GU{_;Ga6HT`i)$Y|q_jw9D zq(5$bleFZ}wFaeDod2#>kt0(iykYyI%KwEb_$T^Nc_`Q{BjiDe?OR)2yNd6<>AJ4( z*y_cO@A@m@ z3IAqR_t4ngH!tRy^U`=L$N7w+u4MkmsUTe0zXm`0Lu1I!DF`3?ef|k$!MKjLGNvTK zbt7Nywbs0l?;$Zkk3+RT*E>gmUnk8Jw8X7+_En#|gbimzU&X4a%XBvk7Hhx{pff=M z&dFtkV-a%Xmd7aacif!S={x5mPnmG46^aw`KZ)7T$`YzH?D>kbEcU)f>{In;$@!7& zw%oLqIRuUDrp583zph_>z`8PT0ac=MZN{b|$9v#Np%La7m(&o^U2Ki@3C zYIG`ixx5I8nE(E&#aH<5w9P>Dm|*IduyMO%`<$0!X=q=-2@d1eMSkCJ*7&bD9usJj z&#Rbw-u5Q01=Xy1=V@diEX|7?xoXn^i!T8W0sF(yRiG$|f4r*nl9YK?E0!8kR1l&g zYD7Q;HHzSZXp$ZV5Md;!L=jPOLy5+XqXDDL9X#iLIDf!-?tRYtq3WsXs(Rjf``7Q% z!T#4fF9LuikJ*sm;pXD$?gD}4@1ErVaEhpz>{SG+eNL1E#5EQ%zIb?i z+mtMABSUDVLH&&C_y^82gw2XAxDmEo*R(qK9m1?znPq->> z@V3!BZHwiyQJNgTBUmT&2>&&?uIdN1YX8-(yJB{`98NQ=OX*OI#4HY=dj#N&-4@E8 zo2!EWIT{EC>~ug?(ziaqoMKRpQ?r;_ZfM76R#Wc`+w7FjIVeq~NUiyI3i388DvgF( zUu{-Q07-NauCUJ>&0NIW&)xC0c(1~MmSqA$I*s-AU|5T3tL?*_AzKrcU@d)Q+M(ua z((Z{DQ`FAA8HG#fcBKKY``!<#_k|MzU$(!TE!X2*?x@Fy zv0WG|PRb=Rr2)W)E;p0Tz?9iQB0VZpZcj=3&>AwwtZSidSJ|}r{cEJp^70Fw=PTxC zIM4(tou0|FVHawxq5!8gD_3-lZM`@3&8KtsHqjK$0ZN)+p|s^?*S3^5Q|31|Gbpi!p<2IMi54BETD-7i}ug*gK zQNr7yGk+_my{%rYj64s{{sMVo6C%dUi`^u`KJNE5=c`;@1FRI z(LI@ill{ylG_E?F(LAxbA;PrhWK8I$ot#I0B}f*VP7ny`>82MChQ}F#Q!{75mZcqMs z!p7C>-)~5)jBIMU80ECT*@QL4xX;6-)2V*^6&!87@nbdt2sk2Jn_|FLz~*}Ln*9am z=pu$*(v(0B+BO~9A7(qeux8nWqGKVa=U0DU$3Et`%S|;m-!d8P15wH#eL3(_6;1KK z_6M;SanT8u6oQT)U`U2dXQP!M%5YP4Q;J=l(i&aj7?S{s_fNG$pr8#XbOu4U0f!|u zuGWGUD0~x_;PoqO_8%Q>)kc6TKM*sMq$G2vvGx8cOU z!WvqrIKTs*UdhPD=a#qHo08(>L+=XLtPTD-{8?&)85el^oM`y4`u_GJlyalrIdKvP zK;DLm?KP*bo?O;;@BYI{M!?%)L+zyF(Fck<{;KUiR-63k{#bqZFR4E*JNKhO|GgIm z;qkWO0xe&_H|19RchZviwvj8Qw@Yn$7qKr#aH}>Qxm0X^wD?k?s1Wvy2G;kn$#BMi zhxjnvR3bGpi;_lJ>Eaw?c^Vt95Q=>E%v+Txk1@<)4;y5qnJ91a5kIafrAAKG+4;*9 zg-}Wg6d~Uq*ai;hdnrbyN>`GK5F6<@|30+9ctOXv-sS)6|J`!78koG+YW7e6pDr%e zZ?k3R8u0>I`PHm^jv4zQd+pAQ;+<9bbTQKZ@=Wo=BLxhyePAdH0of*K2Ex=(E|i#O z&k}(00bZV%g(d*R7s^d}AmjQ&gXZojDq^7dZNKtKV{iDPHfD9uHc0D0u~;wT5tN>< z@z3VD?Azg-c*ZXCTHoW`HzfRrql2zWZ#44!uGBpX!+$z)=V|QGuY&AM3z{En9}{L) z%91c|+<&k#qTX})saR{rLkIVDOgp*e-2RU5jF5WD2nGTPr@L-3EuNSy4cH;evIo+v zmTobI@4m6<3Mr9q52(8K@Z3C$+Qu>Lr+F71tZO&_T5iLX_LbVmmoeGx*G&p1f=^5~ zDxc)%q&n!LMsm_s19Q}P8^s)CXAa=z;9*8W{5-3Kc{>8*-g&fH4Dwd$H-G_a3v3vW z>{&;^<4F}$VXw|GKX%Vd8nS}YXxVqRC6>)X*_dsEY<(%ts>H;cun15$8CoN0zHipZ z4EU8ou{WH>RdLEKAc2YB%i$fwnnyl{-dM#5k6iA8I-a>3HH;BXrU(mT zoOnnm&1EB`0ZWc$98@-ne`b(YvCcUM47?Zo=pLlP*V`Sw8h6o&2W-SOlCjGR zr!39w+nRF1spFST+aA}(yKL*PU6oe$lVNFR@`9(U*FGEGrVSZm8t>82%eP$b0B zIu;fID0XH_Y>2f%M zOmsVv(n4B*!Bl zs}_1>rorOO$xtV-eTrx9UQ<&8W-;OviBux(d zKq5cO*j(brVa$dRz>h#__Asd8W)y)!UCJbzz!ZA4y%ZhrQ%ERlg|vLycJPj}pu;IC z(5H;(sfF}_z8Vl%SxT5Ck~9DPoH;9jM4>{Uq5lQ=xO?8 z^OaQs6bzVx=|$i{It+Lm%mdZu&b)zp#6g@q^`Z-_giX#BUa(?mMLz#oH>e8Y3 zM*yUO0YfN7N)SpfxDrqfFobiHH*f+n_^^n)2|I)h0(u`T;74#_9?aoZa*86M5BeAt zOQ9hWhZLDZTU3RKQ{V={O%eDLj)~L6gF4c>~UBhuzY9IMyCXu$MfZ?c@d?r{caD%xxQ>VP`@w!>Z(&O zFQo2!e>HX4on-x*_|My7?j#$myHP7Yzwdo?FW>nLnq9->R!3=jgPC2lo1lPsMlPu`(@^Y&G$bvmI#!hrl}8Z^S4*NUHrpJ zpOUTXZ89~~F0=G|3*F`4mQFd#EU(b#lN1z?wopX7L31V!%0LU5*c{(@jrmvplMN;GkR_7NL;183paIYa zEF+zY-@m6UBX87*NRazdF@7M_A4 zghe1-Z-n$PlK{^gWCmV|56z1#Th#gJ_R?BxR&6fHqVM%y_QrnJIf=dw+LXL3ylcm( z>)y!1g{6I$R^mq+R<^AldUNZH^3u`uI`vieLede3W_p=In$J00u;u@0tTwb6`1JXM%b* zCZ8d~CA!R!wb`KNO-l@IPxXFdB*Z0r+y)EGjl(mndClEm5yn*;q6Mr7Ho#6mTm;48 zF!9>d*0&xus5_A;+p-nI2PJtN8!$*@*cq@vcaQQ6xcUe->z`ESZh7(D>Kl=ur=0lh z#*(X*Je9;orC-->xulcvSK$)1Q)ZpA$1BJ`v6&81FHk-2485Sv4dt~oIu_bwW6HBc zp#@#4Jc!E0fML8uYCtN?`F0!tsCig5LaxSHX=sWKk5|Hm6QG=J%@S{f9NsWHbPr^> z?uKtHmygj;Tl=AOw z*H5?{KUvZgZ?nAM`0zVhtftwQ@2{>nTsZ5M_0$V9z8bUDJc(qB4WD9TG_gEIFJSxCCa=! z^gZM&g!pXZLQE>R(J!3CR!1Z9IU?L%667Dp^9xP66*FKLBb8%(xtOOe@VbWD2e8U{ zQPgxduW|lKAg~ELb;HERxJlv494~T;vfm z$1%F~vk4FzWn_`Egltk^F5W6-0ju5|#tPU(vT(qm2SNqWRpN@b6k-mFuScA_vXfcc zSI`t;xL`T{b4~qV{rg$3pV;2t8dvnG%py-6K7UTqsrx@2`XhSPDOFeB#ks9@-n$PB zi)JTH8cMzWc<*R`>cvAP;H9UvPH%k#3)6p$90V65{hQlDLhnxgELAQO+N&l>3SvHd zb#8&PH*e}hxXsrA>a~3HL$Aj!=}fn7_EEUq??(&CgHNhn;S@&AE^UtD9%NdOAYTwh z&j4363V!)PDM37t;u%;T$;W6XZD`ih4032mF00!rmPy@}vB(H`F&Jq53u?olYsGPm zW|THSkI?2wSVj>RO#g_6(S2Z1oz74wC8QT2AT$q21NO*T5^U3G)JQicmrjQ)(i94S z5!53^C{0jeumJ@gI$cAXN}pbG(3Yke1xD%SgR_)~mYTDIwKbg&EeDN7Tn{O#wH5V_48@x%WxAI{!C6v!b%YEhsxVv~xcbT%_|5nEN2fA+nNaAXLij4EGt$(#4ph^or@h|`X V|3=yWgsJ|kFZZ7?*niiV{{z|p4<-Nr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b50a130f58939ce98d410d89dcf6d7f4d9b6d6cb GIT binary patch literal 3873 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..74f7e723aa2e0fe21b32bddb81902ed0ac842005 GIT binary patch literal 10785 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

!Gf+?-;l zvOAwtrTu+`vO8|4J&%sr6^?x=L7klznnz5SN}O-0xfaWa3X3uPJ7KJL3;tghbF6+p tKkwSUG$hKjl8?O{1`?#NpHE0_pGj;8NEs?W*696zrvI;i{Qv49{|9m~EH3~6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7676e073f3813deeb4e18e13784ff98c07e46775 GIT binary patch literal 28449 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2908a4fc46f88c84741d0fa4bbb7d07f2632409c GIT binary patch literal 3105 zcmc(h`CC(G7RS$fm*j>l1_Brm^s+^yF^ZB0V zER8o(Flkd)rlcfHL?r-e&e&v~8)1ks8aNn#I-Ct4*__<7?Rl1%$g>VW5p(k9pZIyQ zEV5+M7xQUTv;E3G`@~!J$cIGQ?n9*oLCX6Tc{ASe--DcvSQb99bjJ}Q(FEs$&vlj zd{Ypr%ep;FCxtz35Tv&xa0*F9p_7s;3{GvQOFz)HEWSVIc6xSNxmkjT#YdKSJnKP< zE?(F|En#Lo$+)XAsZqMH-CIXH5;IqW#FyaX(K^`bxpzr@7@p6?!;&uDYo2l3J=xLO z1+N)bQzdds0oM$jN>|*R1a~8-wOp-aKXBou!?lDrNS23!$(wBv)=FfnpLsXRbShgL z>$lk7)ig&oMWf)?si_T^o0ybz%V4mgV|ZFQ`0Nfx@Uuw+Z>W>O9_lJ~reik-mZ^LlWvn!`! z_V|yFKW+amcd%%DeEg5h<~&3FFs!(7rOPSjc!`3@D%#shbaQXBQDQxDv;V22p zeH5;+5Osb$Vnx3dFW$3DK@lAUBej79#-Wq^FWB*rm^qNJs~#10*QG72plU!j$Cf9o8-j z=?9iJS_6vHV!pRo(tBFm;vL7Anvp|5YdS?{&9}A0lFY}mNx=T@@La{iR`0elOS*>& zLK4p@{JIN8i-NW-c(nv%&j2eD6UkMMV0x!sfda(9S05bVU|H+te?%r2%_MC2R0D5G z!jtCBJz4o~TR+qLe*D)>yy9U;%;H1GR;7@OKF+h=SX*#s`kl2Ai4cD5j(65p7e*X3 z=SdyrJprQA2nB&Tk)sf+cZ0~!xtYX}EbyHuBV2lp)1iRDE-AqeQg)5)du$;?+Zbh) zn4U|PCOFj2Yp_PlZSMWLcNP&PoyZI~Sdfg!3S637rd zJAf1UvG+qbg+^YS36Y|pW~I|5B41+S;#ISIx4v~&Tqsze;Rc(5#mo(rP|CUnmn)&p zT$mM~dM1(xzrSVs{y6Zwm}XZ8go|UI%@qgtbX^mYUX|`otWhufLiJ1RZ?}imqtio2 z|M4Vs;Yh(V#i`@^2DqGZ#Q))m^+gYg9i=S2$(~=wCiw?IT4` z>EPPJ{A3ljYrP8S|G=%$Zi$F^a@%;&%z4h?m}wi4EiqbYIAIgpwG@|)B-rGJ|J~7< z=5l>i55@7363h%R$h-iyah0}?{83664c2?m9RZjX*;tXAJ+lH*5Vl#`Mcy;f^Yv@$ z!XsTkd|?NA8-Y6FurNPe0y3e;a5!duME&9ni{jX5!)IqNc^BCW#w+Jlt1hqZdN*l@ zPILd;-EMgGzz0iDK70O;)=nqgD;GQlq3yia9Uo`DIS$!N$K?tEgR~=QcSmX zn$oTTO%gM4Mliv-A0%J)$X#T3+&Oo%ls2~lKJ@`?W^p-L3JFSnS zmXPChS+m0oQ_#1({5eYvz3gHjx&~4mx;Kh>eIY^dstanW*@}c<1-xy)P2<$RoDBKA zaLt%2Fg-b^=Fr2ppKSQ&+A9TD>;LS{X#Zo^vF0~_cy`rrWt7^NT@{xyUNru&=*e0% zi5KA8J(`xgqK(CI^?vS1eZW>Fvqx7JqaVGLZ{o~4_g!_@K>%(l>IAGN(Yz&WvEwSh!@B4Km zM5SkgNhfn~UgQ{~+zuVKT&j_-exbC2lYuZw(l!t&`G;XM-mDFSWrP;2oZ8J=2xEErjKUyUr`;rd?jOEm_^a2$xwM|9tbj)#{m% zpj&C+_10e`4TbUVWmj6opK|}zZDLl^bDY1&#uYvblCFe_O@wo|J7X$DEz#(8lk*2V7K7it@mt@4STEeL?S*Ni?&nLl7YGRaD&yU2QE3*r17CF_$ui(voT)9}rH|hwO5;Y-qIW zc76QvE_oH9ZFgzyT0?eO;35;~GMz%eOX_dBuI24b+kCvW_0%e(&v~Dsoc2}6Pp-Li WAZltCZH`M$FdAZ_4UwPzU+jOX*_9~( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6e23a5d6d722c8aa290e19c5fc1db6380a135646 GIT binary patch literal 48417 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..092e768125a4483954a804bf5bfa1aa3d343421b GIT binary patch literal 38892 zcmceG$iI@8)=_g@GxKaA%enmS{_6uEZaEJ7?{LT3KH zZu0m^FV38H1@-A!eRa5D8Kwkrox*i~-ORan4DZn^pOA&ksM9uma^vkc?78v32wCwu zI$1np*2M8SyU53cEZ;+jV3;}nj@i1kbQ>Y-|BUy;w~wDWIVX2lKb+%zv1<0LIdkjI zymo|;Wi#-6&)JQWXMf(Iy_XOrOo*-#&soTB!XUtYI*^?7IR!+9q|XJSBjeNOBA%#A zpGzc{+><_+@$OUUa|O?!aYCgm{GE;7YUh*$=?&>~fmrC|^tnjd(K+dJiFBgBN}tPQ zHvJ-fu8?-ZO*hY`GteF_loH>&x^_(?(LF2Tkb#ucN9XpmM zaHtCRm^OFL+{Ve{XNLRTKCvtu?mlBim_IWojF%=i{(SPJvP_NP5j|&ydkh^idFs3w z;~V+iFd7Q;X2Mt7`OhVId*+m>vnS4-oA`M}S#?#oPvf*n;}?{M`;WhU-uT7^;mVGs ztaDZ2ii%DZHJz%;!{}LI@0*7fk)C80z_frgl4)crz&n?ONd@VMPdQ1X&#G{*2g>J? zIruh`$(WLvxYv)|P9~BvoP|kuG6SFRHTBHlSCjG1WZeBZj+5~0&161mnuAYA)LwyS zMxYO@wjN|CrhPJcF%LD3$2(V_4Yxhro7}a3wJ_+==0{yKfZ1jEjxF(@j3rH!h`{V3(^po{+0j?|2KXzY(9D9R}trNaAI936c zVLZ!5Sx9=Lk3&h(H9gfJ!N#9JX=Z#sH99KcV<87foW{r=_Mv-mE|JTW(6cyxOAgZ- zI)TQ7-O>hWH;D^N$yTyobdV(8hzom!n6N{LVLk34uagkoc$>Y?KEf2#QzA^k-w9Nq z3Mrv_@+~<{j!V1czGN1iL>kB`noAZ7V{w-VgUJT6kQ|eblNDqKS%LcZ;aewdph-G{ zZbcnQS}jzg?l}5%fF@{?JSGk&cag=ki?B!9MCM3@Cgg+iCLu}Q!(aX(AL8?%Sb_B$ zxq44&qTdRH92QJM75!RiA|FaF_M~`5+DQD;YWfZN3M1W0PLWf>cR0SGY?WUWlJp#U zd5q*r8?_HPOg<%_$_L3|ytkiS(FSUe50Dd*UpIj))5b}s2nljf86mHi_mjnBsW4U; zM>df+FlGbBe3yJ&K0(&d60$~YMjxt)K>Ls>;vS5s7wb1UOD@uVn3IUEKgE?|p-J6h zO5_9D2PJxO9Q{toMcpQ{nkF>J&@SXSnI(;5upz`ny0;2T$iIjRN_2@I|H+Tv@Z(?j z@hg7(6F+`Q&I7tK`I29;bCH}QeR>#BmAkeL+4a1-Qpb zL>yDN$L@$23v2%m?oquSNRn?isTjGLoUkLQ6w(|{36-MOJ3r~UHteg z*-1=z>REop&PDPJ@V^H4o*}((wS)YM*m1?~iToVL*3SXY?fji>{Hd+{d<%J+zqy4& zpecRT#GgP(_g2)f8Qkq*)VG$R)h;zPw@KKxkw)8=a2E@qx|?V8H4-l`1x8g9p`KKeE$-JOC+l~2CU-s zt>n)oLy<9*=ZBB+8N$9svH9a}Gt zn9plq=OWe{o4ZEdb_3V|TQd#3yn(kc8_b5az)B#k@fK!*39vQ8N<iL_~ z__2;3r}Edo<@ z7r&q4$6C^v<4rBUV&@`h&ugkC?RfvHc}Y7`LOf`N-GhA9`VF8`NuI}f1;?6l{!|jZ zV`ED4t1^B}r0*qo4@%SZu;)Zl%Ihfsb+zIx)`Cb%_^K-A?-uc6VY)oduj0gpw(~hQ zMtPli90S9=6x`=1fNv;^u!@ zsjl^}7#X`G5~cMR&gJymG9Q)1+Y!?D1l|LhZjH49shGo)wJ#FFYa?{h@|CpY|IrWm zf7H(Zu9;jyQaVMJM1hjhunpo;ze#&iH;o9tJ2H|e{lR6}rVH;OgZG#hg!i|$4jv)- z<&k^jz#cKC-y_AMU;L-SFA}8#29F5u`S0Gn(zW&;*9-S<9)Sk%%Wk8!-n|k;AXC)! z(iC|sn{yIKS)_jvt7oVvj}aktq^so+J93zf{-j zEZ=|Is1%`=q0kV=LF!?(}gP-;>yQ4_6D zkJANG|10|-?vcUbEMd63iPw>vvM9m~iA>j1kw~Jfw|#cuGuD+$wdX6#X_Y<7zw6(6 zOMUgNx8zMNCxm3n306y@^*i}8q$&|uJv3EAZI+-xnHew9E_Z7PHBarO(Vu(3l;zojmI8h~^Ge9;#&hul%f*9qo>e|;4=QaJ zJ7NgziwUWH=bejc?V*eB;L_pkXy^A?aXGDSR}jrp+%B)j>-7Liiq0LaE-0w3j@VtE zs>=4&)$QU1ar_okw`*ThUFF6@Zu+V>5YFyxkate3yyw{ozq*IUJpPEUPnNtxkRQ6| zR}*$D6?c7GnP1j#abKhSV*QG#e|^+<=8XH%gTGb3sEC!1D7dS?L3&A09(m=r8$COB zIv+T)lYs72w4Rl!<#U)(JLWV2F0m{%uzhv1OzK!^GRl%<)hFTwVO)i>eGXhv;;x(< zo!4=$V4WM7r=RO?C>G||QYc$t!6zD}YJE7XOkW`BeOU!Yy`vWReW>|@?V@^-L4rZY z&gTAnW%C8?w(YIA>a=Tc(ttwSwXdr5xa}?_npaSL{gr&(lcF-O<=njf zPC(Y>_Bd6wuAy=2)W(K7VaU`*?TiYUQu*A!o5js^T`UxesWbD#VS4Xh^j{<&H^9VNOMEFh7R76^=!{bd3=Ui7f0jzvH4V#2~tpg- zFhAE~to0afur(jLaEM{nI~Q#yKL7%0xCHxWYh9>x;5I{%nt>q_#TH z&ZF9EZ)}&pZNt4=#@`zpqzA3NXtCNtZpk9u?=EsxFBtdI3*vE4e*Wg7V5D?Ypiq5C z{i5eUkGGC`Z=IgJaYFayOYdJkW8=uhHzwEH^!B`L#{#u)UdMpdB%I66NyJ~n>IAK# zHcqQtsVrdznJzIp1w>*b>U_Bq1ZuEGm)KgK&>9hXfI6Bz##6^!68gS?`CPwSvZi^P{Ql6&YhNyN(HGnE03 z(-{$!!PK!!{a&bQImX`VOuwc(guQsnk?#^^3{rWvlr#s_? z{xrd;6P}$$1pX}R=LOF0I#Vl6s2`%%L~9R0&*_*qWgx#0>QSvoS;d*Ic)``)evI~d z^bO@{jhCGj&hQoNCzYvOkK1aI%sPcg*^Wj-cB9x}m>+P!ff&FlFEFx+5jUSaT8=5H#`>(OvQtBoZrB|A zS5n=tNB-<`7t~UOTG)8+-mP9*jHTQ%(2yvi%Tgn!pRed3+oV&MM(uqpuWQSA zK67V<)980OcyX6(XO2fN$Rz3r)e5NY0Kii5Zs5=8c0kLo~X4V%{Q^U za9sWS?i0H zMp)3ZWEZgGBxDJ6p3e%WXUXP|YA-@&fQSTA zj=KaymeW0y&ipU&7tM?QOI;^)qp#(zA9IHa^FLcT4E_g69vPfU|dT;AHG+bAI-6m+(4GjCKGR#?9)k64c?$lX`u(eX6qxpLKym_*>#&_lV;TD zF{n#>#?gh-3~m}!ec3tL>SWMry5KNty=#0vEwDw0uh`y&CD#V7@gD+;AXd!*6VV4q)_tCRf}cRrC_QYp~C z5=0N;v$n4PaPe4rC>oNaaRh_lQkm9wSrb6OM!7f=NsmYyjGGS8*1O+FA7tx)^ZurN z>Zc4(3Vv47xIidTPxGO~3d4E-;-tWEeE0f$)qhZXK_pV3HU& zqRT~y`;>^1paVT)JRk{fRz*Ra-E=Trp!O5~sIHd2J9ZHMA<#-TqZ!IpL#Cx_Ic>{! zq#=_RsEJCBb_Lnx#T7+>l%p|}eVDob6Ee}|9SXokswnfDj4?uN@ z(?HBITSp)gS0%SL$rkhhL@F(ERBI5T8r&Go_<{K0anpSaLTp`4x(dGSdtcmo)8ui( z_iq2y3nOovGDLj4nSrP|9*L+s)Ymx}d&R>M^}~HTckF-d&c?j(opswE*|h1A?R9tM z2Ieo!U7VJbLdNpdNj(bce$V&U8Y5%1oxw2jpqe z50yeqv1CeNUusdmr4~q5;Fry7IM;+P4II27T?>;8?xE5&`2jjj57sFt;N?&-n@?(@ z4qw~ljG>FD&oY=gch8|V@b!w82}z)phE_r|Fl6F+P@bUm3!_^qa%NJSL(1us0I#jrgEWrXB!}Nj+D4!}>z;inQU% z6mfC$Qfa4}dsy|KrZebFhD+UWH~?G{VI!Q7YUeb&Y=$h89E(V%Tu(r<$zhjdYjkBp zJpmS)=^ocoU@wTpOi^JhU z^(-~4e(aGIzpS}AZ;jqu+DW~Hk&RN1=yu5O0jGwM+fwa^^|OjY%%&_i88WcAq>mUM z@=Gzkx@_Ptv&w?2I}IP!&jFoYF%_2(>1Td_>SszS-`{RH)AK%K%Y#WrnBtQcj(!Ga z@Y%&Qzr~Po1{s_yaP0`lr?;j`Do@6T#fFbmSE{F1{&V8am+2-t zs)<%FAM&{R%&TvyZ`}37hG|nCrkh)zp+&c^SiWpwpKT-SrY_G{d6(~URZpG|E; zf3|DK-)XNN-6yo{GXkq@~kCuExt9$iqRCgX#kEv@WjF~rQ?)cjl(Asx@MU#tO zdiBXyZW}Eu;#15T@QuU@`-|rqh?(WG9f0*At1e&e`b+1U}IxGf}%4_tr9)hsd zk~WbTs2~Q26VOW&`dSE{3~p7<2v%NHgkgjk2BM0hGhLtycB{v8*A{e{8Nos`V)@Gl zFL$B$tMi2k^tS~QOMJs7+Zabkq625pb{4e!C>fDT7!6hn1bE5ov-ktnP*}09kt}PS z^~xHD->Z{cQQ1J`kk=vy9l7~HTTQI$h##iC18Z5gn25-DKpW0-IvBNMI_(T&?Un68 zdZ4v)8wayufC$q4v4qW*h{d|p7O3;-i07V}w0O?!t?$q%txm+$cue^4ua$Y~^k{Rv zeDJQtyXVi&)*I%{I=_1jlX1b>KE@ghk^ZRykIn0}W|?%MY_C%xCY`g<8!$EM#D#(R z5OrmrVwFiKjLx$a$C*4=rZc_G{LM)6AR^aN@oJD1jb;};_45AB>S=Gl@A2jYyfnth zwKRp%<1JK)hr{vaF7XId<)Tv`JooJH*jzJsGd)j_xh_v-E3(B($y!4_U6Umk9YmHb zVG_*Bb#j6L2&Q4m&KNfV02$lVfX8GgI@DHH5LY+R5wCCDaY$;ua;l(ITvipmBFP80 zyxZbFyq!sMtkt1t)kum`9(j#!ow$b9XRX0B8-$2n3X)tCn@Y&X>Gqfufnj3C_%|uU z)bGDn<%7r6U8hvXSypEi>Kwr_Cz^6(>O_qKug}2hJH)Hwr1+ZJcwJgmEvST&cp%v+A9MJESBh)0WLmEyfP<1}5Hi38Jg`2XM8l{HXxQ18Dbn!ZkEU_;)6w$5 zmNP>1au?yfmKs1Mfr{CjF>M&MfUt@rjF&AkUO|G!*`j9cM}hqx;#oW9*KIfIg)B>B zmOP)-I}jifHU#w!&<*yg?ZPFl$1!ArhQlc?AfU-5bT`9b=%#Ux-|v$TUU~F`=broE zl=?BlVYC7)67^`s#IVR9Vvu@b$YSk=Iq@?uqs0X-sCBX3zy^>(3 z0rk2ml??iQPMc9D>hXTIh4_O}qh1GPD#zq=iP=u88EUnan6pVvln_q8m=pt4VZSh! zaZbyo3B6~u)gEy&D;RiC4KzZ=WNYG#yMo>pz!K|nJ20s2<4i`1XalQx4qc~S5=Fh@ zp{p<7ue0h5pu5fLs3*-j+GoX0!o|y7L{pciCenNElxg!mFHxk z#0!0^#N)6U6j3qKdNVOvbS8rnt}%;ZGFSzNhgk9r#3bZZZDgTl8;QX<5(;46IMV%pG>*hK@87>!LqN9v z*gE{7afD%_co55w^@UM2jd!_8`&0k{W;RJ#kT@|uTMk6Uv2-|CkCfm1JNe+!2h_KBt!+8L zprJN%%4J3X(6Cf_T7QVd^8AHvmqn6{jdrOq&!3w&-@ecn@cYD|D=$KHvMWYpOQ?v$ z2OY6}Q)=%5VD)K3{42aa}o=I`s+ z)gCbaYNPa?MzuKT?&J2ROOHNY>~nuSdzyjLeN3)q(EJ|QrzbH+hr>ay7|lX#i{#02 zIHMM;B(I4|;8ZzT4!FxHA|G7*&=YeP zDSG|PW$OKjTMG-uRJW(yyLM|CinSC0o*suhP)=s1YD)_9Lr$ADOET$vL2o=F=a;%d zN|J~sU4CQ8&qV|O{0e7@)umTTtt76Oy-L_B2T7>H#tZ?B$FSAK78u3`t3X;{&@Aj3 zO&3)4hkOPaua~9;9JnVyLNrVJK3tEK=cB-zyn6ZP%*DH*cga^<$<8ZVcCiX`*b*AP+_kQY!4g zJaWIHOxM6fAQd1Uv_N1g8|NRGERZ&^J}~#UeT(^KRehoIQrjxhI7!>+>;i0l86#(gkx0|7^klu2euH+Ls0G)(@x3 z5s=yO@m~xa_IN+>HjO7DB30Ut7J}r)R9PU~Wiz80y&LU#VXev*gKn4IW;Q84Z@Sqa z@%lXAm!6RA5Sq4q#62k7nxQc?Sk)L$xV9f{J;Gg1RK=a^Xkk>=$p%pW8)=6w)TS6D zqps|~pHg@Amvo}B4&?P%{}+BW@wv`me|_x-``!7w@j@{Ot+#mAh%4V?77$fKLzkKPy{fH?>%0i`$6f>(u1VUUzWx~P2|vQW zZ)0!yTSJs_DBdpGT#vTv^=PyR5)=0oW!eS%ftxK_P0y%Z-+x~|`0vrL%Zb-e2i+_# zQ^v|Ka*Ll)l7+{}qdJ&iV52z7ND)kx;1ckPQW_PPHq$Tu3eEPxzsfJR9H(>Cm8=&; z&6SAyH6%&+O2`w!;}T>!=2p3Mp&Y}FAhH0(N70s7Iw#CSe?{VC9vDeTJ4)~@d<-;R z<)t6Ker2L`?(@&NEO0^CEbWI@QAMVwI#-mGMWH#$a%GMaJi}BhmK9V6{CT1$YfGrI ztjW4Lw9%8}b&vv6Kqj7Il5~_+$ca2taXU_AL6zX|)kGLJv5*=L+5W_A^O`M@n+ZUF z!4sJXrr8qP{Jm-Z0ri);fioTm#%)GE|y3U*9bE}uBfp(PiwPgnjb0fl`d+&Snm6pn6zdUz|!_lehqzRYD%qui|2J{+Q zke64m3lJ!2JtwRKyv&FV?UyR@+1*?Okg~%e(PG>ZkhZutTek)_TEZ5GjwE~)A)78~ zDbHiB|R24+;s*gJ!b<>q>C|+NV8=9Xa1xIxqHf-qd z9%FMPDhO+ea`WP_A7FL=9o824p ztU@AWFUtZ-6mw*_>TQ5^>?Z(B-BlW7e*oAvBXWe6jJ+Pb9ORqC{b$btur1a$=zuR+}eHk;Ne5Mj>=Qi zRom7+O(l-E8;e5Gm_YI)xrHrtJ-XjkC+r=0Tc>Uluk3u~(Uprwjpm9n;EH%gP8VF@ z-#G?R=QI=B7TspL)d-quN*XGiLXwob85v+&31i(rRM0j?-k4z^v*6n-PbFC9xud!b z8>U`ny<`i!YiBHMuegpa_vV?58euk>w^#(fK{S{w(d`HZOG>0%&!L89E`4^C*H6JM zQKj09YBye)#0fePTHjE=qgA{HE38vK$)+xs*K^0mi2=y)N(`}?7!fYs8 z;VvcRX&cIUUJ>5U*ig_P@glG@pZqM9h~|RcKpvGuyG+C0Ef!4PcE5vQF&K=29B0yA zm6HrqMI}@i-FBjs}rnBp)p-M4|Yn?FQ1czHK+M9&?0aJhJuhUjD?uxIU7;kroOxVjcbT4R zl#rXV+5$DwG!7QW>)Bs5M59dKsiIznFi|@UKV3#em}hw%<8Cbtg+YiE-_i*kJ4~J= zcAPZ1bJvNPHOye{A~g*2j12Q^)TrSeN4*@k)C(1z6kQiqU*|268V9cW{k)-j9c zIpIObG38`zs;V$A7H0PT?2xZWin$VD#a2$B?3 zRqL4m1MyZ9Y~EojR+BS1BLTUIWsIb0F2O@E%i(CrM1f>P9v-#{O<#C$(<5tP{%6>G zPrxR;*tBL{lP3Gzm1|qr<79s3x3M zSAjA!)(jlbY({pW2UP|R^cQArz3pe>+25Q#{hOAmn%p;TxkH3k2o$CD0@l(-%xh)J ze+@<1Xe8Sa$b}iL1X1Di1lY#~t;$R1HQ<#qRVIUE8FPnSSl3FF$VkYkRAOtIah(ZW zI!>G@{-t?FJQ$9N5A)b{BCJ5W*H{I%XRHEd6ERD2m}1G@W^pX1^lxtT$vjN5X&pBd7A1gB*`E_ z+wM;7)l`Sb9+G;jw1_Gc!J(CRur3C z-f!7J<9TChI!^Yfp8{`MFM^h_`SXDuL}0-dvoUFMllZLHygl3Nal)Nx%2Fhen6iE5 z?2O$4A-~*`#7M0S>ZFBR!9aG6<)Dgw=$$(E%q*CMm0i@75EV%$n~j|>=^vf`hTWyh zOG?vQo-bZo*JDk+(1GDF5E(In8(~90lz^~A+OAtd;0k~Tg9XnW0vPx*Va5X|Glhv& zZrjJdqhEjV#WB%zaA)%=9?7^#9VgC_45zluSL z1ziCi3?z&ur(wkb1#%4Z(6|Q55$cO50d7}i&|p-JSp;AgV9>f2Mpps1y4UUZCem<; z-8Cf*6-yiRZkP<{$#45|!t)>GFq!>8^Xj=_fCO;6^Y7~y=J*#q{Oz4LG6<2qv`M}! zMo9qnOQ**{tvNZ zAYZO~WmMVdGGFp^Pb zwN;B7P`*I!TRw{!gw(&Pbq}I$A@M<$4e5sC4jh1_m<&%Rh) ztcxKFLku~o5bKiNHPpo^2B1w~k-&lvY}M?bX&dNA7j38hq;h5Jr$P@z%|pSNW{oCu zaC!2lLJ}5F>({?->VN@LXBGH!;{`!~!A}G!2zU3dtLr~t>eK;&!oomqegPXflbM*e zKaPkSZRCGwMOYU~m=|NHauFqk>5`SLy=Vza;7=8+74{CxL1xhUwDomf!hKpCj+N<> zRjo_4ci=@h!%IL?_&cjwm#5!>?F-sVob*bUtZc30J&1#MUSHDlKbCxQUCF*{YFl+( z$^L6fK4m4CW8RL^2PIXAKpH?EObsea6cu{qidd*1&!RKrR461U7UbEi9ZLUHH+V7^nYvGqyM^`ADafB*Vv{rm;7K(L??_@Y@W807n;XZR1T z!_zYi8ycVczHM_q>OYk9Z2eCqpIleM<~~!~s&om{=Qr?nusa8SbY!Kvo3+#WHcFVL zA4e{>R?;(FvKJ*guPgZ^U2+5^Yh>0==ABhqN!nV$MtW1awohBf{$M;q(j_ae>ow~^ zrsT&X&6NCjq?wY;NV)9{*<)90`!oXsCW-R#jA-kD);BJrURTmH-RmEhd~#h0gF&XY zRjdT`+|*hxW}`L}DNT9x(msRm5_wsH?_DQ$muV`iyHRIO=R%};6SIPq%W(+1JeC8I zC3H=ldXDCvqJH(u^%KSSeoLpTkNmc|h6y53afGlGBQ(Oh&*C3nbNq(9W_rb!)x+9T z?BVuh;xo7tg0dtq#uk{D5K&T^3ev7U9nx>=l>U9EOzC^~3wsye{rn4Z&6K|Vr_}ZB zJ7wvw@4jpAp2dszVl0DOON9OM3ixq{q>6J~ey`JPGv*S%ml}+=CZnvAHv7Fzk!Uzz zNQ84tqLSy75~Kj!sz@aUa0ZsqhnT51lX{j>eXcc1YAI`j=dK-l z_r2FzeDA$`?7BI0pis1D6@&=mJ+oeZiAC+U)WoCF_?Mr~yU%#)l>H&}o@FbX1#K@Q zQ&R0*p6sk5N}O5QjkZwM8xftYp|p@TlDtqXV1OU1G*(m?h*8NcC&ErW@-bv{5}-VE zvi4+cEkc1f>&^5Z!-5O%;z@)=aPuXXG+H$~bz8y?QH>GhuiC#qX#3EyI*S!5uYpGuW=sOadSBn$C0`ck*a zYAC4)bSoWTHB`J=8neD>=)Lg9ZXHY9SyBh1>=!v=G zzUoUX1dk0HQWJMr!(R<%C!xx-y+#*NM{oHX`VTPu z3K4FQO!^xXcs3Eo+RTzI*!qA50Z|xAwf52AAb#BN=%&Yi_3lS6$cJZL-XV{;yhF4p zZ>-<+CZjP7vmcbd0KFZXYF84=7YoDwoFKAVvcy7@Ic&3g!ok8Ox!%*{A|{s}>ZsQ( zll+{7O^+67d1=}aqv_eK6OhhM9>tWhS>>t(myqG)$oOLz)?zN(Y_eK>1WgI?sD1j>8MCKPSO32D5%rttbu-(ypG*fzhQD{H?N+;Z z{?CWZb*roC5OufMJo1)XzgoEH>@ByBA3I{`=XWgseB_w13+K$NQ!lN5O#R2yX|w0f zoJNgnryLl)^Ec3^GSJF~6xt6y!(t0km0%Hg*LyhUV5Q+*`{ByB3l4TY(4Xpvxu2!9 zZwFmJSBL)LzP*^-<8(qbEm_B><^l8)}F70#UmbL3P484&)xZ}dn5B{~04jesV)VW1>eLix;*s-IA z|6$49UyK+zW+9$d|MBQ!>i2as<}}Qxr{;B!P%Ekfbv!Pvlea@&F~JHoFjZvG38EFD zjiQVwK9RVo0n0|O+$aswXITc?2ABsr3^!&CB5tIt158j`K0;zKkcq6&t#2TG6tLe!1h39r8Z)MLMwM43yz1>?=fPt2feL zgU+mkm;3@`1@0w>cKib`d9>`MC^xBbpa1ymKZH>EWpi)F^Xn9ZAFP)hG)|-DnpdOk3#(}?sehIA?K_9=wxUG<3dZuDt zDz1^&Ip`WvN-FC5(O%11>%q)pv zV*(w+yB4gkFr@!Lwu3M*py@MTGf(!y}aFD)vxHToA8&aX^V`XZrxU0EqY zfJiW3SK=dKlWuT+%t;3OLY10#PK!xMGfvJ^IRnrV!w`g$PEuzS7!NTAKE|a9Z+6(z zoN1r4+ zJN3@3p5MP9OqVX?4#-|5T}||{(FKukyyaX;X%-!Ie7D{HtFMaZEo#1WYKPs`^rz(P zd4SC^a#%hqsvI^+(j^s2`15^PVjR}4_x(q#S?@>QD>spRpF_E+*l|;^9Qep%_B5G6 zQ~tR9f{lt(S#3DFYlw~a8d~FJMvb(*D(+4!95i;`jGOu|`mXHGL1TVC@A~VW{ePUd@)t`k z?0NZ*4ZuCD0u`&^Fves>jzo@h%%UtY>5m!665*&#M{QZM9X?j01TGC^V@S^^Wb

1XTAZ$O) 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fc117d84c64142ca207976da64af0f4284b3a2e3 GIT binary patch literal 1897 zcmV-v2bTDWP)PVkc=DF9})sFo1wK-LzDs2re5@BAR(@I!-OV;xY`xsUjQ^w zR9L&A(XQ(RbR}CNv}7c4Y&VIW#wNCpx1p+~@X&NLD2olpYiP!5Tn&#o9QfaK!Y<4$Wjj_(ANDxXW z=R7Vki3$P$+o2EQFFi0gBqx(eC6!7sS(cSjvDDvg+lLLq=#O=Ekw73wIhUe@kThc~ zB}v?%loCmj?)ajVQbNcl-w7dvF^2yN05^oACu6D6Xw0&#-cUB%%j@;}p=PuBv}pzc zhG9^{Ff>`_ej$j)7$cwRz?Ou-JOSXUaEy)}kSr_1Wm(ZzS67c`(wX6SB4IK{zbeae zpU2~&s;V(bk{Dx*2q8ZC0)RafjG z6GA)_k0*}CVzI0&%TZaD2_cjSfmi?_93M>=lv1LHLgAA=J?U}3&o>#4L=0J$WlR+S z2*(}z_wA?od_JUU+7p?Kbv76bKC0`wB1saqApi)+XC3+4QNzk)js}9EA0-lr{i>>Z z3Beni00_qxPMmyFrIel;9v(SunC5Y>*UJ$M0EA;(Pn|w97>~!#C6meT`h0$qQi@;z zARJqJ?C`iZmFhW_%jI4Og+jxUBrya70O8nDI2xl96DJ~?ru{6J>p!XM9s|Jua6kUP z{@@qC^kpg5)paPH&b$x~hfi|OF=YT?;@IBt6O*#4D9;X$jQ%(hiKHo|6wv@cIJR}_ zn`b1i*Y{jsuJ7ewDC9*j0I*fLtFzC1Pu6w)dCThkiQn%>Gyo8eyPN#RQ(V(L-|y|s zzU1@ye250X-sR|WSC1V03fENioMmNRMl=9m$8cELJ{IqePYmuKdeJlkhz0=cyj3}N z>{u`oiT)rOjRp`60N8Qjc=YijK~+_M5sgL<3Lz+#3jppKj-mXpl+EVOCKAarj4=sI z1pqsSV`TJm`!ebDd0qEP*rEWyj^Q}-^tZTa8ZU(-kv?3?4*+%yM>razo12{{v)SAs zy3-9x007}2gph|HJ`fn_A2{vtctY5s0Kksn7#2!L`Zy1xf6(RsQ3dc}Buc(Uh zqG1>crVRja6pm_j?MOOf4d62y0N^MbJsC^t>+2i$csx-|8vx)a9Jy?t6$wWkmn3}F z3jiF1qbFn0p?p5=(e;P0Bmm$j9En6iX|r`Eo|E+LI~37 zY!aSI^-x7oac&C09-k&Y4#j$W_+|{`CF#->ba+))ku&LjLm3b;pmzg%Ekh7{#YX0o)yp zT5Y{rC=`AdZ%urR;)?z=b6%d+y@H!fegCoguc$T*?`;MVQQ;^M;M&6_u$U0zf^Tv(8{o(aXukFfF4@?{Y?wdBMAAB(T%IlY2EAHA>2uls% zE(;6uf3MeTukPk^ARGWLnl@UkX5&`5{Hv9fl?K8A0FJ`3yu93$IKO!P+SOhCL?^-l z;1WUz(r7f^oSK@tz1z=)Z~(YOt7Q{P*{_Sm;u^vM0FJgP?RHy~N~K0&;h(#H;u*pL z;F^h}DVCNB_vLI=gag1Ogb?LwwOT+p0KoZ+nbAt4v9XMB0Kk7WbddXWjSPeX01yrU zKsW#Z;Q#=H0{{>X001XAW0Ww)2(EMv@Zh495{WVTncK?mRybByE2LN~;=U>X4|F&P jsn_cp?RL9 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")