diff --git a/.env.example b/.env.example index c19c1d24..c0b27a92 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ HACKATHON_END_TIME= MC_RCON_ADDRESS= MC_RCON_PORT= MC_RCON_PASSWORD= +MC_PUBLIC_IP= +MC_PUBLIC_PORT= SB_BASE_THRESHOLD=8 SB_BIG_THRESHOLD=24 SB_RATELIMIT=30 diff --git a/poetry.lock b/poetry.lock index 212ff96d..6152c59b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "aio-mc-rcon" version = "3.2.0" description = "An async library for utilizing remote console on Minecraft Java Edition servers" +category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -15,6 +16,7 @@ files = [ name = "aiohttp" version = "3.8.4" description = "Async http client/server framework (asyncio)" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -123,6 +125,7 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -137,6 +140,7 @@ frozenlist = ">=1.1.0" name = "apscheduler" version = "3.10.1" description = "In-process task scheduler with Cron-like capabilities" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -148,7 +152,7 @@ files = [ pytz = "*" setuptools = ">=0.7" six = ">=1.4.0" -tzlocal = ">=2.0,<3.dev0 || >=4.dev0" +tzlocal = ">=2.0,<3.0.0 || >=4.0.0" [package.extras] doc = ["sphinx", "sphinx-rtd-theme"] @@ -166,6 +170,7 @@ zookeeper = ["kazoo"] name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -173,10 +178,26 @@ files = [ {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] +[[package]] +name = "asyncio-dgram" +version = "2.1.2" +description = "Higher level Datagram support for Asyncio" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "asyncio-dgram-2.1.2.tar.gz", hash = "sha256:bc28a90bc0523009fb0da16ca983c1400ff403a315754d86e037910563697f91"}, + {file = "asyncio_dgram-2.1.2-py3-none-any.whl", hash = "sha256:9ef55fc760f93c8212709329a1e28a1cf1c1f0fc8222f1be0227c2b7606a10a2"}, +] + +[package.extras] +test = ["black (>=20.8b1)", "flake8 (>=3.8.3)", "mypy (>=0.812)", "mypy-extensions (>=0.4.3)", "pytest (>=5.4.3)", "pytest-asyncio (>=0.14.0)", "typed-ast (>=1.4.3)", "typing-extensions (>=3.10.0.0)"] + [[package]] name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -195,6 +216,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "beautifulsoup4" version = "4.12.2" description = "Screen-scraping library" +category = "main" optional = false python-versions = ">=3.6.0" files = [ @@ -213,6 +235,7 @@ lxml = ["lxml"] name = "black" version = "23.3.0" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -261,6 +284,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -272,6 +296,7 @@ files = [ name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -356,6 +381,7 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -370,6 +396,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -381,6 +408,7 @@ files = [ name = "discord-py" version = "2.3.1" description = "A Python wrapper for the Discord API" +category = "main" optional = false python-versions = ">=3.8.0" files = [ @@ -397,10 +425,31 @@ speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"] test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"] voice = ["PyNaCl (>=1.3.0,<1.6)"] +[[package]] +name = "dnspython" +version = "2.4.2" +description = "DNS toolkit" +category = "main" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "dnspython-2.4.2-py3-none-any.whl", hash = "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8"}, + {file = "dnspython-2.4.2.tar.gz", hash = "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984"}, +] + +[package.extras] +dnssec = ["cryptography (>=2.6,<42.0)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=0.17.3)", "httpx (>=0.24.1)"] +doq = ["aioquic (>=0.9.20)"] +idna = ["idna (>=2.1,<4.0)"] +trio = ["trio (>=0.14,<0.23)"] +wmi = ["wmi (>=1.5.1,<2.0.0)"] + [[package]] name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -415,6 +464,7 @@ test = ["pytest (>=6)"] name = "frozenlist" version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -498,6 +548,7 @@ files = [ name = "greenlet" version = "2.0.2" description = "Lightweight in-process concurrent programming" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" files = [ @@ -506,6 +557,7 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, + {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -514,6 +566,7 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, + {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -543,6 +596,7 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, + {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -551,6 +605,7 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, + {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -571,6 +626,7 @@ test = ["objgraph", "psutil"] name = "humanize" version = "4.7.0" description = "Python humanize utilities" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -585,6 +641,7 @@ tests = ["freezegun", "pytest", "pytest-cov"] name = "icalendar" version = "5.0.7" description = "iCalendar parser/generator" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -600,6 +657,7 @@ pytz = "*" name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -611,6 +669,7 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -618,10 +677,27 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "mcstatus" +version = "11.1.0" +description = "A library to query Minecraft Servers for their status and capabilities." +category = "main" +optional = false +python-versions = ">=3.8.1,<4" +files = [ + {file = "mcstatus-11.1.0-py3-none-any.whl", hash = "sha256:15b81f777ac1413f5a450d221eac913dc1581169b633bd7d2417c96eb49ee965"}, + {file = "mcstatus-11.1.0.tar.gz", hash = "sha256:d14bf46db8ae5b98a68aebb7fce91a169282e9ac09eb7f6a10a20d9e7d18ca80"}, +] + +[package.dependencies] +asyncio-dgram = ">=2.1.2,<3.0.0" +dnspython = ">=2.4.2,<3.0.0" + [[package]] name = "multidict" version = "6.0.4" description = "multidict implementation" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -705,6 +781,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -716,6 +793,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -730,6 +808,7 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -741,6 +820,7 @@ files = [ name = "pathspec" version = "0.11.1" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -752,6 +832,7 @@ files = [ name = "platformdirs" version = "3.8.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -767,6 +848,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest- name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -782,6 +864,7 @@ testing = ["pytest", "pytest-benchmark"] name = "psycopg2-binary" version = "2.9.6" description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -851,13 +934,14 @@ files = [ [[package]] name = "pyright" -version = "1.1.316" +version = "1.1.334" description = "Command line wrapper for pyright" +category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.316-py3-none-any.whl", hash = "sha256:7259d73287c882f933d8cd88c238ef02336e172171ae95117a963a962a1fed4a"}, - {file = "pyright-1.1.316.tar.gz", hash = "sha256:bac1baf8567b90f2082ec95b61fc1cb50a68917119212c5608a72210870c6a9a"}, + {file = "pyright-1.1.334-py3-none-any.whl", hash = "sha256:dcb13e8358e021189672c4d6ebcad192ab061e4c7225036973ec493183c6da68"}, + {file = "pyright-1.1.334.tar.gz", hash = "sha256:3adaf10f1f4209575dc022f9c897f7ef024639b7ea5b3cbe49302147e6949cd4"}, ] [package.dependencies] @@ -871,6 +955,7 @@ dev = ["twine (>=3.4.1)"] name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -893,6 +978,7 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-datafiles" version = "3.0.0" description = "py.test plugin to create a 'tmp_path' containing predefined files/directories." +category = "dev" optional = false python-versions = "*" files = [ @@ -907,6 +993,7 @@ pytest = ">=3.6" name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -921,6 +1008,7 @@ six = ">=1.5" name = "python-dotenv" version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -935,6 +1023,7 @@ cli = ["click (>=5.0)"] name = "pytz" version = "2023.3" description = "World timezone definitions, modern and historical" +category = "main" optional = false python-versions = "*" files = [ @@ -946,6 +1035,7 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -995,6 +1085,7 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1016,6 +1107,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1032,6 +1124,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1043,6 +1136,7 @@ files = [ name = "soupsieve" version = "2.4.1" description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1054,6 +1148,7 @@ files = [ name = "sqlalchemy" version = "2.0.17" description = "Database Abstraction Library" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1101,7 +1196,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} +greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} psycopg2-binary = {version = "*", optional = true, markers = "extra == \"postgresql_psycopg2binary\""} typing-extensions = ">=4.2.0" @@ -1133,6 +1228,7 @@ sqlcipher = ["sqlcipher3-binary"] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1144,6 +1240,7 @@ files = [ name = "types-beautifulsoup4" version = "4.12.0.5" description = "Typing stubs for beautifulsoup4" +category = "dev" optional = false python-versions = "*" files = [ @@ -1158,6 +1255,7 @@ types-html5lib = "*" name = "types-html5lib" version = "1.1.11.14" description = "Typing stubs for html5lib" +category = "dev" optional = false python-versions = "*" files = [ @@ -1169,6 +1267,7 @@ files = [ name = "types-python-dateutil" version = "2.8.19.13" description = "Typing stubs for python-dateutil" +category = "dev" optional = false python-versions = "*" files = [ @@ -1180,6 +1279,7 @@ files = [ name = "types-pytz" version = "2023.3.0.0" description = "Typing stubs for pytz" +category = "dev" optional = false python-versions = "*" files = [ @@ -1191,6 +1291,7 @@ files = [ name = "types-requests" version = "2.31.0.1" description = "Typing stubs for requests" +category = "dev" optional = false python-versions = "*" files = [ @@ -1205,6 +1306,7 @@ types-urllib3 = "*" name = "types-urllib3" version = "1.26.25.13" description = "Typing stubs for urllib3" +category = "dev" optional = false python-versions = "*" files = [ @@ -1216,6 +1318,7 @@ files = [ name = "typing-extensions" version = "4.7.0" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1227,6 +1330,7 @@ files = [ name = "tzdata" version = "2023.3" description = "Provider of IANA time zone data" +category = "main" optional = false python-versions = ">=2" files = [ @@ -1238,6 +1342,7 @@ files = [ name = "tzlocal" version = "5.0.1" description = "tzinfo object for the local timezone" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1255,6 +1360,7 @@ devenv = ["black", "check-manifest", "flake8", "pyroma", "pytest (>=4.3)", "pyte name = "urllib3" version = "2.0.3" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1272,6 +1378,7 @@ zstd = ["zstandard (>=0.18.0)"] name = "yarl" version = "1.9.2" description = "Yet another URL library" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1358,4 +1465,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c83c2c52dd6a94fb55fb0e23afc7d8e3b9d0b761e7b3d77add8a2ad70bda4379" +content-hash = "8332e7448e7a8f79b27e4e3f2597e5cfb8b312368ce0bf95c5cb13c32dc90da9" diff --git a/pyproject.toml b/pyproject.toml index 18bc2814..2f225a04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ humanize = "^4.3" aiohttp = "^3.8" aio-mc-rcon = "^3.2.0" PyYAML = "^6.0" +mcstatus = "^11.1.0" [tool.poetry.scripts] botdev = "dev.cli:main" @@ -41,7 +42,6 @@ build-backend = "poetry.core.masonry.api" [tool.pyright] strict = ["**"] exclude = [ - "**/advent.py", "**/bot.py", "**/error_handler.py", "**/events.py", diff --git a/unimplemented/calendar.py b/unimplemented/calendar.py deleted file mode 100644 index a14806b0..00000000 --- a/unimplemented/calendar.py +++ /dev/null @@ -1,74 +0,0 @@ -from datetime import datetime -from icalendar import Calendar, Event -from uuid import uuid4 as uuid -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status, success_status -from uqcsbot.utils.uq_course_utils import (get_course_assessment, - get_parsed_assessment_due_date, - HttpException, - CourseNotFoundException, - ProfileNotFoundException, - DateSyntaxException) - -# Maximum number of courses supported by !calendar to reduce call abuse. -COURSE_LIMIT = 6 - - -def get_calendar(assessment): - """ - Returns a compiled calendar containing the given assessment. - """ - calendar = Calendar() - for assessment_item in assessment: - course, task, due_date, weight = assessment_item - event = Event() - event['uid'] = str(uuid()) - event['summary'] = f'{course} ({weight}): {task}' - try: - start_datetime, end_datetime = get_parsed_assessment_due_date(assessment_item) - except DateSyntaxException as e: - bot.logger.error(e.message) - # If we can't parse a date, set its due date to today - # and let the user know through its summary. - # TODO(mitch): Keep track of these instances to attempt to accurately - # parse them in future. Will require manual detection + parsing. - start_datetime = end_datetime = datetime.today() - event['summary'] = ("WARNING: DATE PARSING FAILED\n" - "Please manually set date for event!\n" - "The provided due date from UQ was" - + f" '{due_date}\'. {event['summary']}") - event.add('dtstart', start_datetime) - event.add('dtend', end_datetime) - calendar.add_component(event) - return calendar.to_ical() - - -@bot.on_command('calendar') -@success_status -@loading_status -def handle_calendar(command: Command): - """ - `!calendar [COURSE CODE 2] ...` - Returns a compiled - calendar containing all the assessment for a given list of course codes. - """ - channel = bot.channels.get(command.channel_id) - course_names = command.arg.split() if command.has_arg() else [channel.name] - - if len(course_names) > COURSE_LIMIT: - bot.post_message(channel, f'Cannot process more than {COURSE_LIMIT} courses.') - return - - try: - assessment = get_course_assessment(course_names) - except HttpException as e: - bot.logger.error(e.message) - bot.post_message(channel, f'An error occurred, please try again.') - return - except (CourseNotFoundException, ProfileNotFoundException) as e: - bot.post_message(channel, e.message) - return - - user_direct_channel = bot.channels.get(command.user_id) - bot.api.files.upload(title='Importable calendar containing your assessment!', - channels=user_direct_channel.id, filetype='text/calendar', - filename='assessment.ics', file=get_calendar(assessment)) diff --git a/unimplemented/channel_log.py b/unimplemented/channel_log.py deleted file mode 100644 index d1c5c175..00000000 --- a/unimplemented/channel_log.py +++ /dev/null @@ -1,13 +0,0 @@ -from uqcsbot import bot - - -@bot.on("channel_created") -def channel_log(evt: dict): - """ - Notes when channels are created in #uqcs-meta - - @no_help - """ - bot.post_message(bot.channels.get("uqcs-meta"), - 'New Channel Created: ' - + f'<#{evt.get("channel").get("id")}|{evt.get("channel").get("name")}>') diff --git a/unimplemented/coin.py b/unimplemented/coin.py deleted file mode 100644 index ca5d2ae4..00000000 --- a/unimplemented/coin.py +++ /dev/null @@ -1,20 +0,0 @@ -from random import choice -from uqcsbot import bot, Command - - -@bot.on_command("coin") -def handle_coin(command: Command): - """ - `!coin [number]` - Flips 1 or more coins. - """ - if command.has_arg() and command.arg.isnumeric(): - flips = min(max(int(command.arg), 1), 500) - else: - flips = 1 - - response = [] - emoji = (':heads:', ':tails:') - for i in range(flips): - response.append(choice(emoji)) - - bot.post_message(command.channel_id, "".join(response)) diff --git a/unimplemented/cookbook.py b/unimplemented/cookbook.py deleted file mode 100644 index 34d5948a..00000000 --- a/unimplemented/cookbook.py +++ /dev/null @@ -1,10 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("cookbook") -def handle_cookbook(command: Command): - """ - `!cookbook` - Returns the URL of the UQCS student-compiled cookbook (pdf). - """ - bot.post_message(command.channel_id, "It's A Cookbook!\n" - "https://github.com/UQComputingSociety/cookbook") diff --git a/unimplemented/dominos.py b/unimplemented/dominos.py deleted file mode 100644 index a75318be..00000000 --- a/unimplemented/dominos.py +++ /dev/null @@ -1,116 +0,0 @@ -import argparse -from uqcsbot import bot, Command -from bs4 import BeautifulSoup -from datetime import datetime -from requests.exceptions import RequestException -from typing import List -from uqcsbot.utils.command_utils import loading_status, UsageSyntaxException -import requests - -MAX_COUPONS = 10 # Prevents abuse -COUPONESE_DOMINOS_URL = 'https://www.couponese.com/store/dominos.com.au/' - - -class Coupon: - def __init__(self, code: str, expiry_date: str, description: str) -> None: - self.code = code - self.expiry_date = expiry_date - self.description = description - - def is_valid(self) -> bool: - try: - expiry_date = datetime.strptime(self.expiry_date, '%Y-%m-%d') - now = datetime.now() - return all([expiry_date.year >= now.year, expiry_date.month >= now.month, - expiry_date.day >= now.day]) - except ValueError: - return True - - def keyword_matches(self, keyword: str) -> bool: - return keyword.lower() in self.description.lower() - - -@bot.on_command("dominos") -@loading_status -def handle_dominos(command: Command): - """ - `!dominos [--num] N [--expiry] ` - Returns a list of dominos coupons (default: 5 | max: 10) - """ - command_args = command.arg.split() if command.has_arg() else [] - - parser = argparse.ArgumentParser() - - def usage_error(*args, **kwargs): - raise UsageSyntaxException() - parser.error = usage_error # type: ignore - parser.add_argument('-n', '--num', default=5, type=int) - parser.add_argument('-e', '--expiry', action='store_true') - parser.add_argument('keywords', nargs='*') - - args = parser.parse_args(command_args) - coupons_amount = min(args.num, MAX_COUPONS) - coupons = get_coupons(coupons_amount, args.expiry, args.keywords) - - message = "" - for coupon in coupons: - message += f"Code: *{coupon.code}* - {coupon.description}\n" - bot.post_message(command.channel_id, message) - - -def filter_coupons(coupons: List[Coupon], keywords: List[str]) -> List[Coupon]: - """ - Filters coupons iff a keyword is found in the description. - """ - return [coupon for coupon in coupons if - any(coupon.keyword_matches(keyword) for keyword in keywords)] - - -def get_coupons(n: int, ignore_expiry: bool, keywords: List[str]) -> List[Coupon]: - """ - Returns a list of n Coupons - """ - - coupon_page = get_coupon_page() - if coupon_page is None: - return None - - coupons = get_coupons_from_page(coupon_page) - - if not ignore_expiry: - coupons = [coupon for coupon in coupons if coupon.is_valid()] - - if keywords: - coupons = filter_coupons(coupons, keywords) - return coupons[:n] - - -def get_coupons_from_page(coupon_page: bytes) -> List[Coupon]: - """ - Strips results from html page and returns a list of Coupon(s) - """ - soup = BeautifulSoup(coupon_page, 'html.parser') - soup_coupons = soup.find_all(class_="ov-coupon") - - coupons = [] - - for soup_coupon in soup_coupons: - expiry_date_str = soup_coupon.find(class_='ov-expiry').get_text(strip=True) - description = soup_coupon.find(class_='ov-desc').get_text(strip=True) - code = soup_coupon.find(class_='ov-code').get_text(strip=True) - coupon = Coupon(code, expiry_date_str, description) - coupons.append(coupon) - - return coupons - - -def get_coupon_page() -> bytes: - """ - Gets the coupon page HTML - """ - try: - response = requests.get(COUPONESE_DOMINOS_URL) - return response.content - except RequestException as e: - bot.logger.error(e.response.content) - return None diff --git a/unimplemented/emoji_log.py b/unimplemented/emoji_log.py deleted file mode 100644 index aedb8d12..00000000 --- a/unimplemented/emoji_log.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Logs emoji addition/removal to emoji-request for audit purposes -""" -from uqcsbot import bot - - -@bot.on("emoji_changed") -def emoji_log(evt: dict): - """ - Notes when emojis are added or deleted. - - @no_help - """ - emoji_request = bot.channels.get("emoji-request") - subtype = evt.get("subtype") - - if subtype == 'add': - name = evt["name"] - value = evt["value"] - - if value.startswith('alias:'): - _, alias = value.split('alias:') - - bot.post_message(emoji_request, - f'Emoji alias added: `:{name}:` :arrow_right: `:{alias}:` (:{name}:)') - - else: - message = bot.post_message(emoji_request, f'Emoji added: :{name}: (`:{name}:`)') - bot.api.reactions.add(channel=message["channel"], - timestamp=message["ts"], name=name) - - elif subtype == 'remove': - names = evt.get("names") - removed = ', '.join(f'`:{name}:`' for name in names) - plural = 's' if len(names) > 1 else '' - - bot.post_message(emoji_request, f'Emoji{plural} removed: {removed}') diff --git a/unimplemented/emojify.py b/unimplemented/emojify.py deleted file mode 100644 index af131ec3..00000000 --- a/unimplemented/emojify.py +++ /dev/null @@ -1,173 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status -from typing import Dict, List - -from collections import defaultdict -from random import shuffle, choice - - -@bot.on_command("emojify") -@loading_status -def handle_emojify(command: Command): - ''' - `!emojify text` - converts text to emoji. - ''' - master: Dict[str, List[str]] = defaultdict(lambda: [":grey_question:"]) - - # letters - master['A'] = [":adobe:", ":airbnb:", ":amazon:", ":anarchism:", - ":arch:", ":atlassian:", ":office_access:", ":capital_a_agile:", - choice([":card-ace-clubs:", ":card-ace-diamonds:", - ":card-ace-hearts:", ":card-ace-spades:"])] - master['B'] = [":bhinking:", ":bitcoin:", ":blutes:"] - master['C'] = [":c:", ":clang:", ":cplusplus:", ":copyright:", ":clipchamp:", - ":clipchamp_old:"] - master['D'] = [":d:", ":disney:", ":deloitte:"] - master['E'] = [":ecorp:", ":emacs:", ":erlang:", ":ie10:", ":thonk_slow:", ":edge:", - ":expedia_group:"] - master['F'] = [":f:", ":facebook:", ":flutter:", ":figma:"] - master['G'] = [":g+:", ":google:", ":nintendo_gamecube:", ":gatsbyjs:", ":gmod:"] - master['H'] = [":hackerrank:", ":homejoy:"] - master['I'] = [":information_source:", ":indoorooshs:"] - master['J'] = [choice([":card-jack-clubs:", ":card-jack-diamonds:", - ":card-jack-hearts:", ":card-jack-spades:"])] - master['K'] = [":kickstarter:", ":kotlin:", - choice([":card-king-clubs:", ":card-king-diamonds:", - ":card-king-hearts:", ":card-king-spades:"])] - master['L'] = [":l:", ":lime:", ":l_plate:", ":ti_nekro:"] - master['M'] = [":gmail:", ":maccas:", ":mcgrathnicol:", ":melange_mining:", ":mtg:", ":mxnet:", - ":jmod:"] - master['N'] = [":nano:", ":neovim:", ":netscape_navigator:", ":notion:", - ":nginx:", ":nintendo_64:", ":office_onenote:", ":netflix-n:"] - master['O'] = [":office_outlook:", ":oracle:", ":o_:", ":tetris_o:", ":ubuntu:", - choice([":portal_blue:", ":portal_orange:"])] - master['P'] = [":auspost:", ":office_powerpoint:", ":office_publisher:", - ":pinterest:", ":paypal:", ":producthunt:", ":uqpain:"] - master['Q'] = [":quora:", ":quantium:", choice([":card-queen-clubs:", ":card-queen-diamonds:", - ":card-queen-hearts:", ":card-queen-spades:"])] - master['R'] = [":r-project:", ":rust:", ":redroom:", ":registered:"] - master['S'] = [":s:", ":skedulo:", ":stanford:", ":stripe_s:", ":sublime:", ":tetris_s:"] - master['T'] = [":tanda:", choice([":telstra:", ":telstra-pink:"]), - ":tesla:", ":tetris_t:", ":torchwood:", ":tumblr:", ":nyt:"] - master['U'] = [":uber:", ":uqu:", ":the_horns:", ":proctoru:", ":ubiquiti:"] - master['V'] = [":vim:", ":vue:", ":vuetify:", ":v:"] - master['W'] = [":office_word:", ":washio:", ":wesfarmers:", ":westpac:", - ":weyland_consortium:", ":wikipedia_w:", ":woolworths:"] - master['X'] = [":atlassian_old:", ":aginicx:", ":sonarr:", ":x-files:", ":xbox:", - ":x:", ":flag-scotland:", ":office_excel:"] - master['Y'] = [":hackernews:"] - master['Z'] = [":tetris_z:"] - - # numbers - master['0'] = [":chrome:", ":suncorp:", ":disney_zero:", ":firefox:", - ":mars:", ":0_:", choice([":dvd:", ":cd:"])] - master['1'] = [":techone:", ":testtube:", ":thonk_ping:", ":first_place_medal:", - ":critical_fail:", ":slack_unread_1:"] - master['2'] = [":second_place_medal:", choice([":card-2-clubs:", ":card-2-diamonds:", - ":card-2-hearts:", ":card-2-spades:"])] - master['3'] = [":css:", ":third_place_medal:", choice([":card-3-clubs:", ":card-3-diamonds:", - ":card-3-hearts:", ":card-3-spades:"])] - master['4'] = [choice([":card-4-clubs:", ":card-4-diamonds:", - ":card-4-hearts:"]), ":card-4-spades:"] - master['5'] = [":html:", choice([":card-5-clubs:", ":card-5-diamonds:", - ":card-5-hearts:", ":card-5-spades:"])] - master['6'] = [choice([":card-6-clubs:", ":card-6-diamonds:", - ":card-6-hearts:", ":card-6-spades:"])] - master['7'] = [choice([":card-7-clubs:", ":card-7-diamonds:", - ":card-7-hearts:", ":card-7-spades:"])] - master['8'] = [":8ball:", choice([":card-8-clubs:", ":card-8-diamonds:", - ":card-8-hearts:", ":card-8-spades:"])] - master['9'] = [choice([":card-9-clubs:", ":card-9-diamonds:", - ":card-9-hearts:", ":card-9-spades:"])] - - # whitespace - master[' '] = [":whitespace:"] - master['\n'] = ["\n"] - - # other ascii characters (sorted by ascii value) - master['!'] = [":exclamation:"] - master['"'] = [choice([":ldquo:", ":rdquo:"]), ":pig_nose:"] - master['\''] = [":apostrophe:"] - master['#'] = [":slack_old:", ":csharp:"] - master['$'] = [":thonk_money:", ":moneybag:"] - # '&' converts to '&' - master['&'] = [":ampersand:", ":dnd:"] - master['('] = [":lparen:"] - master[')'] = [":rparen:"] - master['*'] = [":day:", ":nab:", ":youtried:", ":msn_star:", ":rune_prayer:", ":wolfram:", - ":shuriken:", ":mtg_s:", ":aoc:", ":jetstar:"] - master['+'] = [":tf2_medic:", ":flag-ch:", ":flag-england:"] - master['-'] = [":no_entry:"] - master['.'] = [":full_stop_big:"] - master[','] = [":comma:"] - master['/'] = [":slash:"] - master[';'] = [":semi-colon:"] - # '>' converts to '>' - master['>'] = [":accenture:", ":implying:", ":plex:", ":powershell:"] - master['?'] = [":question:"] - master['@'] = [":whip:"] - master['^'] = [":this:", ":typographical_carrot:", ":arrow_up:", - ":this_but_it's_an_actual_caret:"] - master['~'] = [":wavy_dash:"] - - # slack/uqcsbot convert the following to other symbols - - # greek letters - # 'Α' converts to 'A' - master['Α'] = [":alpha:"] - # 'Β' converts to 'B' - master['Β'] = [":beta:"] - # 'Δ' converts to 'D' - master['Δ'] = [":optiver:"] - # 'Λ' converts to 'L' - master['Λ'] = [":halflife:", ":haskell:", ":lambda:", ":racket:", - choice([":uqcs:", ":scrollinguqcs:", ":scrollinguqcs_alt:", ":uqcs_mono:"])] - # 'Π' converts to 'P' - master['Π'] = [":pi:"] - # 'Φ' converts to 'PH' - master['Φ'] = [":phyrexia_blue:"] - # 'Σ' converts to 'S' - master['Σ'] = [":polymathian:", ":sigma:"] - - # other symbols (sorted by unicode value) - # '…' converts to '...' - master['…'] = [":lastpass:"] - # '€' converts to 'EUR' - master['€'] = [":martian_euro:"] - # '√' converts to '[?]' - master['√'] = [":sqrt:"] - # '∞' converts to '[?]' - master['∞'] = [":arduino:", ":visualstudio:", ":infinitely:"] - # '∴' converts to '[?]' - master['∴'] = [":julia:"] - - master['人'] = [":人:"] - - master[chr(127)] = [":delet_this:"] - - text = "" - if command.has_arg(): - text = command.arg.upper() - # revert HTML conversions - text = text.replace(">", ">") - text = text.replace("<", "<") - text = text.replace("&", "&") - - lexicon = {} - for character in set(text+'…'): - full, part = divmod((text+'…').count(character), len(master[character])) - shuffle(master[character]) - lexicon[character] = full * master[character] + master[character][:part] - shuffle(lexicon[character]) - - ellipsis = lexicon['…'].pop() - - response = "" - for character in text: - emoji = lexicon[character].pop() - if len(response + emoji + ellipsis) > 4000: - response += ellipsis - break - response += emoji - - bot.post_message(command.channel_id, response) diff --git a/unimplemented/history.py b/unimplemented/history.py deleted file mode 100644 index 2dd62122..00000000 --- a/unimplemented/history.py +++ /dev/null @@ -1,56 +0,0 @@ -from uqcsbot import bot -from datetime import datetime -from pytz import timezone, utc -from random import choice - - -class Pin: - """ - Class for pins, with channel, age in years, user and pin text - """ - def __init__(self, channel: str, years: int, user: str, text: str): - self.channel = channel - self.years = years - self.user = user - self.text = text - - def message(self) -> str: - return (f"On this day, {self.years} years ago, <@{self.user}> said" - f"\n>>>{self.text}") - - def origin(self): - return bot.channels.get(self.channel) - - -@bot.on_schedule('cron', hour=12, minute=0, timezone='Australia/Brisbane') -def daily_history() -> None: - """ - Selets a random pin that was posted on this date some years ago, - and reposts it in the same channel - """ - anniversary = [] - today = datetime.now(utc).astimezone(timezone('Australia/Brisbane')).date() - - # for every channel - for channel in bot.api.conversations.list(types="public_channel")['channels']: - # skip archived channels - if channel['is_archived']: - continue - - for pin in bot.api.pins.list(channel=channel['id'])['items']: - # messily get the date the pin was originally posted - pin_date = (datetime.fromtimestamp(int(float(pin['message']['ts'])), tz=utc) - .astimezone(timezone('Australia/Brisbane')).date()) - # if same date as today - if pin_date.month == today.month and pin_date.day == today.day: - # add pin to possibilities - anniversary.append(Pin(channel=channel['name'], years=today.year-pin_date.year, - user=pin['message']['user'], text=pin['message']['text'])) - - # if no pins were posted on this date, do nothing - if not anniversary: - return - - # randomly select a pin, and post it in the original channel - selected = choice(anniversary) - bot.post_message(selected.origin(), selected.message()) diff --git a/unimplemented/id.py b/unimplemented/id.py deleted file mode 100644 index 8d7ff00a..00000000 --- a/unimplemented/id.py +++ /dev/null @@ -1,9 +0,0 @@ -from uqcsbot import bot, Command - - -@bot.on_command("id") -def handle_id(command: Command): - """ - `!id` - Returns the calling user's Slack ID. - """ - bot.post_message(command.channel_id, f'You are Number `{command.user_id}`') diff --git a/unimplemented/link.py b/unimplemented/link.py deleted file mode 100644 index 83674dc0..00000000 --- a/unimplemented/link.py +++ /dev/null @@ -1,163 +0,0 @@ -from argparse import ArgumentParser -from enum import Enum -from typing import Optional, Tuple - -from slackblocks import Attachment, Color, SectionBlock -from sqlalchemy.exc import NoResultFound - -from uqcsbot import bot, Command -from uqcsbot.models import Link -from uqcsbot.utils.command_utils import loading_status - - -class LinkScope(Enum): - """ - Possible requested scopes for setting or retrieving a link. - """ - CHANNEL = "channel" - GLOBAL = "global" - - -class SetResult(Enum): - """ - Possible outcomes of the set link operation. - """ - NEEDS_OVERRIDE = "Link already exists, use `-f` to override" - OVERRIDE_SUCCESS = "Successfully overrode link" - NEW_LINK_SUCCESS = "Successfully added link" - - -def set_link_value(key: str, value: str, channel: str, - override: bool, link_scope: Optional[LinkScope] = None) -> Tuple[SetResult, str]: - """ - Sets a corresponding value for a particular key. Keys are set to global by default but this can - be overridden by passing the channel flag. Existing links can only be overridden if the - override flag is passed. - :param key: the lookup key for users to search the value by - :param value: the value to associate with the key - :param channel: the name of the channel the set operation was initiated in - :param link_scope: defines the scope to set the link in, defaults to global if not provided - :param override: required to be True if an association already exists and needs to be updated - :return: a SetResult status and the value associated with the given key/channel combination - """ - link_channel = channel if link_scope == LinkScope.CHANNEL else None - session = bot.create_db_session() - - try: - exists = session.query(Link).filter(Link.key == key, - Link.channel == link_channel).one() - if exists and not override: - return SetResult.NEEDS_OVERRIDE, exists.value - session.delete(exists) - result = SetResult.OVERRIDE_SUCCESS - except NoResultFound: - result = SetResult.NEW_LINK_SUCCESS - session.add(Link(key=key, channel=link_channel, value=value)) - session.commit() - session.close() - return result, value - - -def get_link_value(key: str, - channel: str, - link_scope: Optional[LinkScope] = None) -> Tuple[Optional[str], Optional[str]]: - """ - Gets the value associated with a given key (and optionally channel). If a channel association - exists, this is returned, otherwise a global association is returned. If no association exists - then None is returned. The default behaviour can be overridden by passing the global flag to - force retrieval of a global association when a channel association exists. - :param key: the key to look up - :param channel: the name of the channel the lookup request was made from - :param link_scope: the requested scope to retrieve the link from (if supplied) - :return: the associated value if an association exists, else None, and the source - (global/channel) if any else None - """ - session = bot.create_db_session() - channel_match = session.query(Link).filter(Link.key == key, - Link.channel == channel).one_or_none() - global_match = session.query(Link).filter(Link.key == key, - Link.channel == None).one_or_none() # noqa: E711 - session.close() - - if link_scope == LinkScope.GLOBAL: - return (global_match.value, "global") if global_match else (None, None) - - if link_scope == LinkScope.CHANNEL: - return (channel_match.value, "channel") if channel_match else (None, None) - - if channel_match: - return channel_match.value, "channel" - - if global_match: - return global_match.value, "global" - - return None, None - - -@bot.on_command('link') -@loading_status -def handle_link(command: Command) -> None: - """ - `!link [-c | -g] [-f] key [value [value ...]]` - Set and retrieve information in a key value - store. Links can be set to be channel specific or global. Links are set as global by default, - and channel specific links are retrieved by default unless overridden with the respective flag. - """ - parser = ArgumentParser("!link", add_help=False) - parser.add_argument("key", type=str, help="Lookup key") - parser.add_argument("value", type=str, help="Value to associate with key", nargs="*") - flag_group = parser.add_mutually_exclusive_group() - flag_group.add_argument("-c", "--channel", action="store_true", dest="channel_flag", - help="Ensure a channel link is retrieved, or none is") - flag_group.add_argument("-g", "--global", action="store_true", dest="global_flag", - help="Ignore channel link and force retrieval of global") - parser.add_argument("-f", "--force-override", action="store_true", dest="override", - help="Must be passed if overriding a link") - - try: - args = parser.parse_args(command.arg.split() if command.has_arg() else []) - except SystemExit: - # Incorrect Usage - return bot.post_message(command.channel_id, "", - attachments=[Attachment(SectionBlock(str(parser.format_help())), - color=Color.YELLOW)._resolve()]) - - channel = bot.channels.get(command.channel_id) - if not channel: - return bot.post_message(command.channel_id, "", attachments=[ - Attachment(SectionBlock("Cannot find channel name, please try again."), - color=Color.YELLOW)._resolve() - ]) - - channel_name = channel.name - - link_scope = LinkScope.CHANNEL if args.channel_flag else \ - LinkScope.GLOBAL if args.global_flag else None - - # Retrieve a link - if not args.value: - link_value, source = get_link_value(key=args.key, - channel=channel_name, - link_scope=link_scope) - channel_text = f" in channel `{channel_name}`" if args.channel_flag else "" - if link_value: - source_text = source if source == 'global' else channel_name - response = f"{args.key} ({source_text}): {link_value}" - else: - response = f"No link found for key: `{args.key}`" + channel_text - color = Color.GREEN if link_value else Color.RED - return bot.post_message(command.channel_id, "", attachments=[ - Attachment(SectionBlock(response), color=color)._resolve() - ]) - - # Set a link - if args.key and args.value: - result, current_value = set_link_value(key=args.key, - channel=channel_name, - value=" ".join(args.value), - override=args.override, - link_scope=link_scope) - color = Color.YELLOW if result == SetResult.NEEDS_OVERRIDE else Color.GREEN - scope = channel_name if args.channel_flag else 'global' - response = f"{args.key} ({scope}): {current_value}" - attachment = Attachment(SectionBlock(response), color=color)._resolve() - bot.post_message(command.channel_id, f"{result.value}:", attachments=[attachment]) diff --git a/unimplemented/pastexams.py b/unimplemented/pastexams.py deleted file mode 100644 index 21cfa759..00000000 --- a/unimplemented/pastexams.py +++ /dev/null @@ -1,67 +0,0 @@ -from uqcsbot import bot, Command -from bs4 import BeautifulSoup -from typing import Iterable, Tuple -import requests -from uqcsbot.utils.command_utils import loading_status - - -@bot.on_command('pastexams') -@loading_status -def handle_pastexams(command: Command): - """ - `!pastexams [COURSE CODE]` - Retrieves past exams for a given course code. - If unspecified, will attempt to find the ECP - for the channel the command was called from. - """ - channel = bot.channels.get(command.channel_id) - course_code = command.arg if command.has_arg() else channel.name - bot.post_message(channel, get_past_exams(course_code)) - - -def get_exam_data(soup: BeautifulSoup) -> Iterable[Tuple[str, str]]: - """ - Takes the soup object of the page and generates each result in the format: - ('year Sem X:', link) - """ - - # The exams are stored in a table with the structure: - # Row 1: A bunch of informational text - # Row 2: Semester information - # Row 3: Links to Exams - # Rows two and three are what we care about. Of those the first column is just a row title so - # we ignore that as well - - exam_table_rows = soup.find('table', class_='maintable').contents - semesters = exam_table_rows[1].find_all('td')[1:] # All columns in row 2 excluding the first - # Gets the content from each td. Text is separated by a
thus result is in the format - # (year,
, 'Sem.x' - semesters = [semester.contents for semester in semesters] - - # Same thing but for links - links = exam_table_rows[2].find_all('td')[1:] - links = [link.find('a')['href'] for link in links] - - for (year, _, semester_id), link in zip(semesters, links): - semester_str = semester_id.replace('.', ' ') - yield f'{year} {semester_str}', link - - -def get_past_exams(course_code: str) -> str: - """ - Gets the past exams for the course with the specified course code. - Returns intuitive error messages if this fails. - """ - url = 'https://www.library.uq.edu.au/exams/papers.php?' - http_response = requests.get(url, params={'stub': course_code}) - - if http_response.status_code != requests.codes.ok: - return "There was a problem getting a response" - - # Check if the course code exists - soup = BeautifulSoup(http_response.content, 'html.parser') - no_course = soup.find('div', class_='page').find('div').contents[0] - if "Sorry. We have not found any past exams for this course" in no_course: - return f"The course code {course_code} did not return any results" - - return '>>>' + '\n'.join((f'*{semester}*: <{link}|PDF>' - for semester, link in get_exam_data(soup))) diff --git a/unimplemented/wavie.py b/unimplemented/wavie.py deleted file mode 100644 index be81b47f..00000000 --- a/unimplemented/wavie.py +++ /dev/null @@ -1,22 +0,0 @@ -from uqcsbot import bot -import logging - - -logger = logging.getLogger(__name__) - - -@bot.on('message') -def wave(evt): - """ - :wave: reacts to "person joined/left this channel" - - @no_help - """ - if evt.get('subtype') not in ['channel_join', 'channel_leave']: - return - chan = bot.channels.get(evt['channel']) - if chan is not None and chan.name == 'announcements': - return - result = bot.api.reactions.add(name='wave', channel=chan.id, timestamp=evt['ts']) - if not result.get('ok'): - logger.error(f"Error adding reaction: {result}") diff --git a/unimplemented/whoami.py b/unimplemented/whoami.py deleted file mode 100644 index 0d1983b7..00000000 --- a/unimplemented/whoami.py +++ /dev/null @@ -1,18 +0,0 @@ -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import success_status - - -@bot.on_command("whoami") -@success_status -def handle_whoami(command: Command): - """ - `!whoami` - Returns the Slack information for the calling user. - """ - response = bot.api.users.info(user=command.user_id) - if not response['ok']: - message = 'An error occurred, please try again.' - else: - user_info = response['user'] - message = f'Your vital statistics: \n```{user_info}```' - user_direct_channel = bot.channels.get(command.user_id) - bot.post_message(user_direct_channel, message) diff --git a/unimplemented/xkcd.py b/unimplemented/xkcd.py deleted file mode 100644 index eaf491a6..00000000 --- a/unimplemented/xkcd.py +++ /dev/null @@ -1,125 +0,0 @@ -import datetime -import requests -import feedparser -import re -from urllib.parse import quote -from uqcsbot import bot, Command -from uqcsbot.utils.command_utils import loading_status - - -# HTTP Endpoints -XKCD_BASE_URL = "https://xkcd.com/" -XKCD_RSS_URL = "https://xkcd.com/rss.xml" -RELEVANT_XKCD_URL = 'https://relevantxkcd.appspot.com/process' - - -def get_by_id(comic_number: int) -> str: - """ - Gets an xkcd comic based on its unique ID/sequence number. - :param comic_number: the ID number of the xkcd comic to retrieve. - :return: a response containing either a comic URL or an error message. - """ - if comic_number <= 0: - return "Invalid xkcd ID, it must be a positive integer." - url = f"{XKCD_BASE_URL}{str(comic_number)}" - response = requests.get(url) - if response.status_code != 200: - return "Could not retrieve an xkcd with that ID (are there even that many?)" - return url - - -def get_by_search_phrase(search_phrase: str) -> str: - """ - Uses the site relevantxkcd.appspot.com to identify the - most appropriate xkcd comic based on the phrase provided. - :param search_phrase: the phrase to find an xkcd comic related to. - :return: the URL of the most relevant comic for that search phrase. - """ - params = {"action": "xkcd", "query": quote(search_phrase)} - response = requests.get(RELEVANT_XKCD_URL, params=params) - # Response consists of a newline delimited list, with two irrelevant first parameters - relevant_comics = response.content.decode().split("\n")[2:] - # Each line consists of "comic_id image_url" - best_response = relevant_comics[0].split(" ") - comic_number = int(best_response[0]) - return get_by_id(comic_number) - - -def get_latest() -> str: - """ - Gets the most recently published xkcd comic by examining the RSS feed. - :return: the URL to the latest xkcd comic. - """ - rss = feedparser.parse(XKCD_RSS_URL) - entries = rss['entries'] - if len(entries) > 0: - i = 0 - latest = entries[i]['guid'] - if not re.match(r"https://xkcd\.com/\d+/", latest): - i += 1 - latest = entries[i]['guid'] - else: - latest = 'https://xkcd.com/2200/' - return latest - - -def is_id(argument: str) -> bool: - """ - Determines whether the given argument is a valid id (i.e. an integer). - :param argument: the string argument to evaluate - :return: true if the argument can be evaluated as an interger, false otherwise - """ - try: - int(argument) - except ValueError: - return False - else: - return True - - -@bot.on_command('xkcd') -@loading_status -def handle_xkcd(command: Command) -> None: - """ - `!xkcd [COMIC_ID|SEARCH_PHRASE]` - Returns the xkcd comic associated - with the given COMIC_ID (an integer) or matching the SEARCH_PHRASE. - Providing no arguments will return the most recent comic. - """ - if command.has_arg(): - argument = command.arg - if is_id(argument): - comic_number = int(argument) - response = get_by_id(comic_number) - else: - response = get_by_search_phrase(command.arg) - else: - response = get_latest() - - bot.post_message(command.channel_id, response, unfurl_links=True, unfurl_media=True) - - -@bot.on_schedule('cron', hour=14, minute=1, day_of_week='mon,wed,fri', - timezone='Australia/Brisbane') -def new_xkcd() -> None: - """ - Posts new xkcd comic when they are released every Monday, - Wednesday & Friday at 4AM UTC or 2PM Brisbane time. - - @no_help - """ - link = get_latest() - - day = datetime.datetime.today().weekday() - if (day == 0): # Monday - message = "It's Monday, 4 days till Friday; here's the" - elif (day == 2): # Wednesday - message = "Half way through the week, time for the" - elif (day == 4): # Friday - message = (":musical_note: It's Friday, Friday\nGotta get down on Friday\n" - "Everybody's lookin' forward to the") - else: - message = "@pah It is day " + str(day) + ", please fix me... Here's the" - message += " latest xkcd comic " - - general = bot.channels.get("general") - bot.post_message(general.id, message + link, unfurl_links=True, unfurl_media=True) diff --git a/uqcsbot/__main__.py b/uqcsbot/__main__.py index 93e7591e..ee848766 100644 --- a/uqcsbot/__main__.py +++ b/uqcsbot/__main__.py @@ -43,6 +43,7 @@ async def main(): "basic", "cat", "cowsay", + "course_ecp", "dominos_coupons", "error_handler", "events", diff --git a/uqcsbot/advent.py b/uqcsbot/advent.py index d37f6563..5f53d60f 100644 --- a/uqcsbot/advent.py +++ b/uqcsbot/advent.py @@ -1,21 +1,29 @@ import io -import logging import os -from argparse import ArgumentParser, Namespace -from datetime import datetime, timedelta, timezone -from enum import Enum +from datetime import datetime from random import choices -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, Iterable, List, Optional, Literal +import requests +from requests.exceptions import RequestException +from sqlalchemy.sql.expression import and_ import discord -import requests +from discord import app_commands from discord.ext import commands -from requests.exceptions import RequestException from uqcsbot.bot import UQCSBot -from uqcsbot.models import AOCWinner -from uqcsbot.utils.command_utils import loading_status +from uqcsbot.models import AOCRegistrations, AOCWinners from uqcsbot.utils.err_log_utils import FatalErrorWithLog +from uqcsbot.utils.advent_utils import ( + Member, + Day, + Json, + InvalidHTTPSCode, + ADVENT_DAYS, + CACHE_TIME, + parse_leaderboard_column_string, + print_leaderboard, +) # Leaderboard API URL with placeholders for year and code. LEADERBOARD_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{code}.json" @@ -23,145 +31,141 @@ # UQCS leaderboard ID. UQCS_LEADERBOARD = 989288 -# Days in Advent of Code. List of numbers 1 to 25. -ADVENT_DAYS = list(range(1, 25 + 1)) - -# Puzzles are unlocked at midnight EST. -EST_TIMEZONE = timezone(timedelta(hours=-5)) - - -class SortMode(Enum): - """Options for sorting the leaderboard.""" - - PART_1 = "p1" - PART_2 = "p2" - DELTA = "delta" - LOCAL = "local" # SortMode.LOCAL is not shown to users - GLOBAL = "global" # SortMode.GLOBAL is not shown to users +# The maximum time in seconds that a person can complete a challenge in. Used as a maximum value to help with sorting when someone whas not attempted a day. +MAXIMUM_TIME_FOR_STAR = 365 * 24 * 60 * 60 + +# --- Sorting Methods & Related Leaderboards --- + +# Star 1 Time: Time for just getting star 1. For the monthly leaderboard, this will be the total time spent on star 1 across all problems. +# Star 2 Time: Time for just getting star 2. Does not include the time to get star 1. For the monthly leaderboard, this will be the total time spent on star 2 across all problems. +# Star 1 & 2 Time: Time for getting both stars 1 and 2. +# Total Time: The total time spent on problems over the entire month. For the monthly leaderboard, this is the same as Star 1 & 2 Time. +# Total Stars: The total number of stars over the entire month. +# Global Rank: The users global rank over the month. This is not reasonable to be daily, as very few get a global ranking each day. +SortingMethod = Literal[ + "Star 1 Time", + "Star 2 Time", + "Star 1 & 2 Time", + "Total Time", + "Total Stars", + "Global Rank", +] + +# Note that a tuple is used so that there can be multiple sorting criterial +sorting_functions_for_day: Dict[ + SortingMethod, Callable[[Member, Day], tuple[int, ...]] +] = { + "Star 1 Time": lambda member, day: ( + member.times[day].get(1, MAXIMUM_TIME_FOR_STAR), + member.times[day].get(2, MAXIMUM_TIME_FOR_STAR), + ), + "Star 2 Time": lambda member, day: ( + member.times[day][2] - member.times[day][1] + if 2 in member.times[day] + else MAXIMUM_TIME_FOR_STAR, + member.times[day].get(1, MAXIMUM_TIME_FOR_STAR), + ), + "Star 1 & 2 Time": lambda member, day: ( + member.times[day].get(2, MAXIMUM_TIME_FOR_STAR), + member.times[day].get(1, MAXIMUM_TIME_FOR_STAR), + ), + "Total Time": lambda member, dat: ( + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + -member.star_total, + ), + "Total Stars": lambda member, day: ( + -member.star_total, + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + ), + "Global Rank": lambda member, day: ( + -member.global_, + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + ), +} - def __str__(self): - return self.value # needed so --help prints string values +# Each sorting method has its own leaderboard to show the most relevant details +leaderboards_for_day: Dict[SortingMethod, str] = { + "Star 1 Time": "# 1 2 3 ! @ T", + "Star 2 Time": "# 1 2 3 ! @ T", + "Star 1 & 2 Time": "# 1 2 3 ! @ T L", + "Total Time": "# T ! @ 1 2 3", + "Total Stars": "# * L 1 2 3", + "Global Rank": "# G L * 1 2 3", +} +# These are used for the monthly leaderboard +sorting_functions_for_month: Dict[ + SortingMethod, Callable[[Member], tuple[int, ...]] +] = { + "Star 1 Time": lambda member: ( + member.get_total_star1_time(default=MAXIMUM_TIME_FOR_STAR), + member.get_total_star2_time(default=MAXIMUM_TIME_FOR_STAR), + ), + "Star 2 Time": lambda member: ( + member.get_total_star2_time(default=MAXIMUM_TIME_FOR_STAR), + member.get_total_star1_time(default=MAXIMUM_TIME_FOR_STAR), + ), + "Star 1 & 2 Time": lambda member: ( + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + -member.star_total, + member.get_total_star1_time(default=MAXIMUM_TIME_FOR_STAR), + ), + "Total Time": lambda member: ( + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + -member.star_total, + ), + "Total Stars": lambda member: ( + -member.star_total, + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + ), + "Global Rank": lambda member: ( + -member.global_, + member.get_total_time(default=MAXIMUM_TIME_FOR_STAR), + ), +} -# Map of sorting options to friendly name. -SORT_LABELS = { - SortMode.PART_1: "part 1 completion", - SortMode.PART_2: "part 2 completion", - SortMode.DELTA: "time delta", +# Each sorting method has its own leaderboard to show the most relevant details +leaderboards_for_month: Dict[SortingMethod, str] = { + "Star 1 Time": "# ! @ T * L", + "Star 2 Time": "# ! @ T * L", + "Star 1 & 2 Time": "# L * T", + "Total Time": "# L * T ! @", + "Total Stars": "# L T B", + "Global Rank": "# G L * T", } -def sort_none_last(key): +class Advent(commands.Cog): """ - Given sort key function, returns new key function which can handle None. - - None values are sorted after non-None values. + All of the commands related to Advent of Code (AOC). + Commands: + /advent help - Display help menu + /advent leaderboard - Display a leaderboard. Many sorting options and different leaderboard styles + /advent register - Register an AOC id to the current discord username. Used for registrating for prizes + /advent register-force - Force a registration between an AOC id and a discord user. Used for moderation and admin reasons + /advent unregister - Unregister an AOC id to the current discord username. + /advent unregister-force - Force-remove a registration between an AOC id and a discord user. Used for moderation and admin reasons + /advent previous-winners - Show the previous winners from a year + /advent new-winner - Add a discord user as a winner (chosen directly or by random selection) for prizes + /advent remove-winner - Remove a winner for the database """ - return lambda x: (key(x) is None, key(x)) - - -# type aliases for documentation purposes. -Day = int # from 1 to 25 -Star = int # 1 or 2 -Seconds = int -Times = Dict[Star, Seconds] -Delta = Optional[Seconds] -# TODO: make these types more specific with TypedDict and Literal when possible. - - -class Member: - def __init__( - self, id: int, name: str, local: int, stars: int, global_: int - ) -> None: - self.id = id - self.name = name - self.local = local - self.stars = stars - self.global_ = global_ - - self.all_times: Dict[Day, Times] = {d: {} for d in ADVENT_DAYS} - self.all_deltas: Dict[Day, Delta] = {d: None for d in ADVENT_DAYS} - - self.day: Optional[Day] = None - self.day_times: Times = {} - self.day_delta: Delta = None - - @classmethod - def from_member_data( - cls, data: Dict, year: int, day: Optional[int] = None - ) -> "Member": - """ - Constructs a Member from the API response. - - Times and delta are calculated for the given year and day. - """ - - member = cls( - data["id"], - data["name"], - data["local_score"], - data["stars"], - data["global_score"], - ) - - for d, day_data in data["completion_day_level"].items(): - d = int(d) - times = member.all_times[d] - - # timestamp of puzzle unlock, rounded to whole seconds - DAY_START = int(datetime(year, 12, d, tzinfo=EST_TIMEZONE).timestamp()) - - for star, star_data in day_data.items(): - star = int(star) - times[star] = int(star_data["get_star_ts"]) - DAY_START - assert times[star] >= 0 - - if len(times) == 2: - part_1, part_2 = sorted(times.values()) - member.all_deltas[d] = part_2 - part_1 - - # if day is specified, save that day's information into the day_ fields. - if day: - member.day = day - member.day_times = member.all_times[day] - member.day_delta = member.all_deltas[day] - - return member - - @staticmethod - def sort_key(sort: SortMode) -> Callable[["Member"], Any]: - """ - Given sort mode, returns a key function which sorts members - by that option using the stored times and delta. - """ - - if sort == SortMode.LOCAL: - # sorts by local score, then stars, descending. - return lambda m: (-m.local, -m.stars) - if sort == SortMode.GLOBAL: - # sorts by global score, then local score, then stars, descending. - return lambda m: (-m.global_, -m.local, -m.stars) - - # these key functions sort in ascending order of the specified value. - # E731 advises using function definitions over lambdas which is unreasonable here - if sort == SortMode.PART_1: - key = lambda m: m.day_times.get(1) # noqa: E731 - elif sort == SortMode.PART_2: - key = lambda m: m.day_times.get(2) # noqa: E731 - elif sort == SortMode.DELTA: - key = lambda m: m.day_delta # noqa: E731 - else: - assert False - - return sort_none_last(key) - -class Advent(commands.Cog): - CHANNEL_NAME = "contests" - - # Session cookie (will expire in approx 30 days). - # See: https://github.com/UQComputingSociety/uqcsbot-discord/wiki/Tokens-and-Environment-Variables#aoc_session_id - SESSION_ID: str = "" + advent_command_group = app_commands.Group( + name="advent", description="Commands for Advent of Code" + ) + + Command = Literal[ + "help", + "leaderboard", + "register", + "register-force", + "unregister", + "unregister-force", + "previous-winners", + "new-winner", + "remove-winner", + "leaderboard_style", + ] def __init__(self, bot: UQCSBot): self.bot = bot @@ -183,372 +187,820 @@ def __init__(self, bot: UQCSBot): month=12, ) - if os.environ.get("AOC_SESSION_ID") is not None: - SESSION_ID = os.environ.get("AOC_SESSION_ID") + # A dictionary from a year to the list of members + self.members_cache: Dict[int, List[Member]] = {} + self.last_reload_time = datetime.now() + + if isinstance((session_id := os.environ.get("AOC_SESSION_ID")), str): + # Session cookie (will expire in approx 30 days). + # See: https://github.com/UQComputingSociety/uqcsbot-discord/wiki/Tokens-and-Environment-Variables#aoc_session_id + self.session_id: str = session_id else: raise FatalErrorWithLog( bot, "Unable to find AoC session ID. Not loading advent cog." ) - def star_char(self, num_stars: int): + @commands.Cog.listener() + async def on_ready(self): + channel = discord.utils.get( + self.bot.uqcs_server.channels, name=self.bot.AOC_CNAME + ) + if isinstance(channel, discord.TextChannel): + self.channel = channel + else: + raise FatalErrorWithLog( + self.bot, + f"Could not find channel #{self.bot.AOC_CNAME} for advent of code cog.", + ) + role = discord.utils.get(self.bot.uqcs_server.roles, name=self.bot.AOC_ROLE) + if isinstance(role, discord.Role): + self.role = role + else: + raise FatalErrorWithLog( + self.bot, + f"Could not find role @{self.bot.AOC_ROLE} for advent of code cog", + ) + + def _get_leaderboard_json(self, year: int, code: int) -> Json: """ - Given a number of stars (0, 1, or 2), returns its leaderboard - representation. + Returns a json dump of the leaderboard """ - return " .*"[num_stars] + try: + response = requests.get( + LEADERBOARD_URL.format(year=year, code=code), + cookies={"session": self.session_id}, + ) + except RequestException as exception: + raise FatalErrorWithLog( + self.bot, + f"Could not get the leaderboard from Advent of Code. For more information {exception}", + ) + if response.status_code != 200: + raise InvalidHTTPSCode( + "Expected a HTTPS status code of 200.", response.status_code + ) + try: + return response.json() + except ValueError as exception: # json.JSONDecodeError + raise FatalErrorWithLog( + self.bot, + f"Could not interpret the JSON from Advent of Code (AOC). This suggests that AOC no longer provides JSON or something went very wrong. For more information: {exception}", + ) - def format_full_leaderboard(self, members: List[Member]) -> str: + def _get_members( + self, year: int, code: int = UQCS_LEADERBOARD, force_refresh: bool = False + ): """ - Returns a string representing the full leaderboard of the given list. - - Full leaderboard includes rank, points, stars (per day), and username. + Returns the list of members in the leaderboard for the given year and leaderboard code. + It will attempt to retrieve from a cache if 15 minutes has not passed. + This can be overriden by setting force refresh. """ + if ( + force_refresh + or (datetime.now() - self.last_reload_time >= CACHE_TIME) + or year not in self.members_cache + ): + leaderboard = self._get_leaderboard_json(year, code) + self.members_cache[year] = [ + Member.from_member_data(data, year) + for data in leaderboard["members"].values() + ] + return self.members_cache[year] - # 3 4 25 - # |-| |--| |-----------------------| - # 1) 751 **************** Name - def format_member(i: int, m: Member): - stars = "".join(self.star_char(len(m.all_times[d])) for d in ADVENT_DAYS) - return f"{i:>3}) {m.local:>4} {stars} {m.name}" - - left = " " * (3 + 2 + 4 + 1) # chars before stars start - header = ( - f"{left} 1111111111222222\n" f"{left}1234567890123456789012345\n" + def _get_registrations(self, year: int) -> Iterable[AOCRegistrations]: + """ + Get all registrations linking an AOC id to a discord account. + """ + db_session = self.bot.create_db_session() + registrations = db_session.query(AOCRegistrations).filter( + AOCRegistrations.year == year ) + db_session.commit() + db_session.close() + return registrations - return header + "\n".join(format_member(i, m) for i, m in enumerate(members, 1)) - - def format_global_leaderboard(self, members: List[Member]) -> str: + async def reminder_fifteen_minutes(self): + """ + The function used within the AOC reminder 15 minutes before each challenge starts. """ - Returns a string representing the global leaderboard of the given list. + await self.channel.send( + f"{self.role.mention} Today's Advent of Code puzzle is released in 15 minutes.", + allowed_mentions=discord.AllowedMentions( + everyone=False, users=False, roles=True + ), + ) - Full leaderboard includes rank, global points, and username. + async def reminder_released(self): """ + The function used within the AOC reminder when each challenge starts. + """ + await self.channel.send( + f"{self.role.mention} Today's Advent of Code puzzle has been released. Good luck!", + allowed_mentions=discord.AllowedMentions( + everyone=False, users=False, roles=True + ), + ) - # 3 4 - # |-| |--| - # 1) 751 Name - def format_member(i: int, m: Member): - return f"{i:>3}) {m.global_:>4} {m.name}" + def _get_previous_winner_aoc_ids(self, year: int) -> List[int]: + """ + Returns a list of all winner aoc ids for a year + """ + db_session = self.bot.create_db_session() + prev_winners = db_session.query(AOCWinners).filter(AOCWinners.year == year) + db_session.commit() + db_session.close() - return "\n".join(format_member(i, m) for i, m in enumerate(members, 1)) + return [winner.aoc_userid for winner in prev_winners] - def format_day_leaderboard(self, members: List[Member]) -> str: + def _add_winners(self, winners: List[Member], year: int, prize: str): """ - Returns a string representing the leaderboard of the given members on - the given day. - - Full leaderboard includes rank, points, stars (per day), and username. + Add all members within the list to the database """ + for winner in winners: + db_session = self.bot.create_db_session() + db_session.add(AOCWinners(aoc_userid=winner.id, year=year, prize=prize)) + db_session.commit() + db_session.close() + + def _random_choices_without_repition( + self, population: List[Member], weights: List[int], k: int + ) -> List[Member]: + """ + Selects k people from a list of members, weighted by weights. + The weight of a person is like how many tickets they have for the lottery. + """ + result: List[Member] = [] + for _ in range(k): + if sum(weights) == 0: + return [] - def format_seconds(seconds: Optional[int]) -> str: - if seconds is None: - return "" - delta = timedelta(seconds=seconds) - if delta > timedelta(hours=24): - return ">24h" - return str(delta) - - # 3 8 8 8 - # |-| |------| |------| |------| - # Part 1 Part 2 Delta - # 1) 0:00:00 0:00:00 0:00:00 Name 1 - # 2) >24h >24h >24h Name 2 - def format_member(i: int, m: Member) -> str: - assert m.day is not None - part_1 = format_seconds(m.day_times.get(1)) - part_2 = format_seconds(m.day_times.get(2)) - delta = format_seconds(m.day_delta) - return f"{i:>3}) {part_1:>8} {part_2:>8} {delta:>8} {m.name}" + result.append(choices(population, weights)[0]) + index = population.index(result[-1]) + population.pop(index) + weights.pop(index) - header = " Part 1 Part 2 Delta\n" - return header + "\n".join(format_member(i, m) for i, m in enumerate(members, 1)) + return result - def format_advent_leaderboard( - self, members: List[Member], is_day: bool, is_global: bool, sort: SortMode - ) -> str: + @advent_command_group.command(name="help") + @app_commands.describe(command="The command you want to view help about.") + async def help_command( + self, interaction: discord.Interaction, command: Optional[Command] = None + ): """ - Returns a leaderboard for the given members with the given options. - - If full is True, leaderboard will show progress for all days, otherwise one - specific day is shown. + Print a help message about advent of code. """ + match command: + case None: + await interaction.response.send_message( + """ +[Advent of Code](https://adventofcode.com/) is a yearly coding competition that occurs during the first 25 days of december. Coding puzzles are released at 3pm AEST each day, with two stars available for each puzzle. You can spend as long as you like on each puzzle, but UQCS also has a private leaderboard with prizes on offer. + +To join, go to and sign in. The UQCS private leaderboard join code is `989288-0ff5a98d`. To be eligible for prizes, you will also have to link your discord account. This can be done by using the `/advent register` command. Reach out to committee if you are having any issues. + +For more help, you can use `/advent help ` to get information about a specific command. + """ + ) + case "help": + await interaction.response.send_message( + """ +`/advent help` is a help menu for all the Advent of Code commands. If you use `/advent help ` you can see details of a particular command. Not much else to say here, try another command. + """ + ) + case "leaderboard": + await interaction.response.send_message( + """ +`/advent leaderboard` displays a leaderboard for the Advent of Code challenges. There are two types of leaderboard: for a single day, and for the entire month. These are selected by either providing the `day` option or not. You can also display the leaderboard for a past year or another leaderboard (say another private leaderboard that you have). + +There are 6 different sorting options, which do slightly different things depending on whether the leaderboard is for a single day or an entire month. The default sorting method changes on which type of leaderboard you want. + `Star 1 Time ` - For single-day leaderboards, this sorts by the shortest time to get star 1 for the given problem. For monthly leaderboards, this sorts by the shortest total star 1 time for all problems. + `Star 2 Time ` - For single-day leaderboards, this sorts by the shortest time to get just star 2 for the given problem. For monthly leaderboards, this sorts by the shortest total star 2 time for all problems. + `Star 1 & 2 Time` - For single-day leaderboards, this sorts by the shortest time to get both stars 1 and 2 for the given problem. For monthly leaderboards, this sorts by the shortest total time working on all the problems. + `Total Time ` - This sorts by the sortest total time working on all the problems. For monthly leaderboards, this is the same as `Star 1 & 2 Time`. + `Total Stars ` - This sorts by the largest number of total stars collected over the month. + `Global ` - This sorts by users global score. Note that this will only show users with global score. + +You can also style the leaderboard (i.e. change the columns). The default style will change depending on whether the leaderboard is for a single-day or the entire month, and depending on the sorting method. Styles consist of a string, with each character representing a column. Use `/advent help leaderboard-style` to see the possoble characters. + """ + ) + case "leaderboard_style": + await interaction.response.send_message( + """ +Not a command, but an option given to the command `/advent leaderboard` controling the columns in the leaderboard. Each character in the given string represents a certain column. The possible characters are: +The characters in the string can be: + `# ` - Provides a column of the form "XXX)" telling the order for the given leaderboard + `1 ` - The time for star 1 for the specific day (daily leaderboards only) + `2 ` - The time for star 2 for the specific day (daily leaderboards only) + `3 ` - The time for both stars for the specific day (daily leaderboards only) + `! ` - The total time spent on first stars for the whole competition + `@ ` - The total time spent on second stars for the whole competition + `T ` - The total time spent overall for the whole competition + `* ` - The total number of stars for the whole competition + `L ` - The local ranking someone has within the UQCS leaderboard + `G ` - The global score someone has + `B ` - A progress bar of the stars each person has + `space` - A padding column of a single character +All other characters will be ignored. + """ + ) + case "register": + await interaction.response.send_message( + """ +`/advent register` links an Advent of Code account and a discord user so that you are eligble for prizes. Each Advent of Code account and discord account can only be linked to one other account each year. Note that registrations last for only the current year. If you are having any issues with this, message committee to help. + """ + ) + case "register-force": + await interaction.response.send_message( + """ +`/advent register-force` is an admin-only command to force a registration (i.e. create a registration between any Advent of Code account and Discord user). This can be used for moderation, if someone is having difficulties registering or if you want to register someone for a previous year. This command can break things (such as creating duplicate registrations), so be careful. Exactly one of `aoc_name` or `aoc_id` should be given. Also note that you need to use the Discord ID, not the discord username. If you have developer options enables on your account, this can be found by right clicking on the user and selecting `Copy User ID`. + """ + ) + case "unregister": + await interaction.response.send_message( + """ +`/advent unregister` unlinks your discord account from the currently linked Advent of Code account. Message committee if you need any help. + """ + ) + case "unregister-force": + await interaction.response.send_message( + """ +`/advent unregister-force` is an admin-only command that removes a registration from the database. This can be used as a moderation tool, to remove someone who has registered to an Advent of Code account that isn't there. Note that you need to use the Discord ID, not the discord username. If you have developer options enables on your account, this can be found by right clicking on the user and selecting `Copy User ID`. + """ + ) + case "previous-winners": + await interaction.response.send_message( + """ +`/advent previous-winners` displays the previous winners for a particular year. Note that the records for year prior to 2022 may not be accurate, as the current system was not in use then. + """ + ) + case "new-winner": + await interaction.response.send_message( + """ +`/advent add-winner` is an admin-only command that allows you to either manually or randomly select winners. Participants will only be eligible to win if they have completed at least one star within the given times. For manual selection, provide an Advent of Code user ID (note that this is not the same as their Advent of Code name), otherwise a random winner will be drawn. + +The arguments for the command have a bit of nuance. They are as follow: + `prize ` - A description of the prize to be given. This will be displayed when the winner is selected and if `/advent previous-winners` is used. + `start` & `end ` - The initial and final dates (inclusive) of the time range of the prize. To be eligible to win, participants need to get a star from ode of these days. The weights of the selected winner are determined from this range as well. + `weights ` - How the winners are selected. For "Equal", each eligible participant has an equal probability of winning. For "Stars", it is as if each user gets a "raffle ticket" for each star they completed within the timeframe, meaning more stars provides a greater chance of winning. + `number_of_winners ` - The number of winners to randomly select. + `allow_repeat_winners ` - This allows participants to win multiple times from the same selection if `number_of_winners` is greater than 1. Note that regardless of this option, someone can win multiple times in a year, just not in a single selection. + `allow_unregistered_users` - This allows Advent of Code accounts that do not have a linked discord account to win. Note that it can be difficult to give out prizes to users that do not have a linked discord. + `year ` - The year the prize is for. + """ + ) + case "remove-winner": + await interaction.response.send_message( + """ +`/advent remove-winner` is an admin-only command that removes a winner from the database. It uses the database ID (which is distinct from the Advent of code user ID and the Discord user ID). You can find these ids by running `/advent previous-winners show_ids:True`. + """ + ) + + @advent_command_group.command(name="leaderboard") + @app_commands.describe( + day="Day of the leaderboard [1-25]. If not given (default), the entire month leaderboard is given.", + year="Year of the leaderboard. Defaults to this year.", + code="The leaderboard code. Defaults to the UQCS leaderboard.", + sortby="The method to sort the leaderboard.", + leaderboard_style="The display format of the leaderboard. See the help menu for more information.", + ) + async def leaderboard_command( + self, + interaction: discord.Interaction, + day: Optional[Day] = None, + year: Optional[int] = None, + code: int = UQCS_LEADERBOARD, + sortby: Optional[SortingMethod] = None, + leaderboard_style: Optional[str] = None, + ): + """ + Display an advent of code leaderboard. + """ + if (not day is None) and (day not in ADVENT_DAYS): + await interaction.response.send_message( + "The day given is not a valid advent of code day." + ) + return - if is_day: - # filter to users who have at least one star on this day. - members = [m for m in members if m.day_times] - members.sort(key=Member.sort_key(sort)) - return self.format_day_leaderboard(members) + await interaction.response.defer(thinking=True) - if is_global: - # filter to users who have global points. - members = [m for m in members if m.global_] - members.sort(key=Member.sort_key(SortMode.GLOBAL)) - return self.format_global_leaderboard(members) + if year is None: + year = datetime.now().year + if sortby is None: + sortby = "Star 1 & 2 Time" if day else "Total Stars" + if leaderboard_style is None: + leaderboard_style = ( + leaderboards_for_day[sortby] if day else leaderboards_for_month[sortby] + ) - members.sort(key=Member.sort_key(SortMode.LOCAL)) - return self.format_full_leaderboard(members) + try: + members = self._get_members(year, code) + except InvalidHTTPSCode: + await interaction.edit_original_response( + content="Error fetching leaderboard data. Check the leaderboard code and year." + ) + return + except AssertionError: + await interaction.edit_original_response( + content="Error parsing leaderboard data." + ) + return - def parse_arguments(self, argv: List[str]) -> Namespace: - """ - Parses !advent arguments from the given list. + if code == UQCS_LEADERBOARD: + message = ":star: *Advent of Code UQCS Leaderboard* :trophy:" + else: + message = f":star: *Advent of Code Leaderboard {code}* :trophy:" - Returns namespace with argument values or throws UsageSyntaxException. - If an exception is thrown, its message should be shown to the user and - execution should NOT continue. - """ - parser = ArgumentParser("!advent", add_help=False) + if day: + message += f"\n:calendar: *Day {day}* (Sorted By {sortby})" + members = [member for member in members if member.attempted_day(day)] + members.sort(key=lambda m: sorting_functions_for_day[sortby](m, day)) + else: + members = [ + member + for member in members + if any(member.attempted_day(day) for day in ADVENT_DAYS) + ] + members.sort(key=sorting_functions_for_month[sortby]) - parser.add_argument( - "day", - type=int, - default=0, - nargs="?", - help="Show leaderboard for specific day" + " (default: all days)", - ) - parser.add_argument( - "-g", - "--global", - action="store_true", - dest="global_", - help="Show global points", - ) - parser.add_argument( - "-y", - "--year", - type=int, - default=datetime.now().year, - help="Year of leaderboard (default: current year)", - ) - parser.add_argument( - "-c", - "--code", - type=int, - default=UQCS_LEADERBOARD, - help="Leaderboard code (default: UQCS leaderboard)", - ) - parser.add_argument( - "-s", - "--sort", - default=SortMode.PART_2, - type=SortMode, - choices=(SortMode.PART_1, SortMode.PART_2, SortMode.DELTA), - help="Sorting method when displaying one day" - + " (default: part 2 completion time)", + if not members: + await interaction.edit_original_response( + content="This leaderboard contains no people." + ) + return + + scoreboard_file = io.BytesIO( + bytes( + print_leaderboard( + parse_leaderboard_column_string(leaderboard_style, self.bot), + members, + day, + ), + "utf-8", + ) ) - parser.add_argument( - "-h", "--help", action="store_true", help="Prints this help message" + await interaction.edit_original_response( + content=message, + attachments=[ + discord.File( + scoreboard_file, + filename=f"advent_{code}_{year}_{day}.txt", + ) + ], ) - # used to propagate usage errors out. - # somewhat hacky. typically, this should be done by subclassing ArgumentParser - def usage_error(message, *args, **kwargs): - raise ValueError(message) + @advent_command_group.command(name="register") + @app_commands.describe( + aoc_name="Your name shown on Advent of Code.", + ) + async def register_command(self, interaction: discord.Interaction, aoc_name: str): + """ + Register for prizes by linking your discord to an Advent of Code name. + """ + # TODO: Check UQCS membership + await interaction.response.defer(thinking=True) + + db_session = self.bot.create_db_session() + year = datetime.now().year - parser.error = usage_error + members = self._get_members(year) + if aoc_name not in [member.name for member in members]: + await interaction.edit_original_response( + content=f"Could not find the Advent of Code name `{aoc_name}` within the UQCS leaderboard." + ) + return + member = [member for member in members if member.name == aoc_name] + if len(member) != 1: + await interaction.edit_original_response( + content=f"Could not find a unique Advent of Code name `{aoc_name}` within the UQCS leaderboard." + ) + member = member[0] + AOC_id = member.id + + query = ( + db_session.query(AOCRegistrations) + .filter( + and_( + AOCRegistrations.year == year, AOCRegistrations.aoc_userid == AOC_id + ) + ) + .one_or_none() + ) + if query is not None: + discord_user = self.bot.uqcs_server.get_member(query.discord_userid) + if discord_user: + discord_ping = discord_user.mention + else: + discord_ping = f"someone who doesn't seem to be in the server (discord id = {query.discord_userid})" + await interaction.edit_original_response( + content=f"Advent of Code name `{aoc_name}` is already registered to {discord_ping}. Please contact committee if this is your Advent of Code name." + ) + return - args = parser.parse_args(argv) + discord_id = interaction.user.id + query = ( + db_session.query(AOCRegistrations) + .filter( + and_( + AOCRegistrations.year == year, + AOCRegistrations.discord_userid == discord_id, + ) + ) + .one_or_none() + ) + if query is not None: + await interaction.edit_original_response( + content=f"Your discord account ({interaction.user.mention}) is already registered to the Advent of Code name `{query.aoc_userid}`. You'll need to unregister to change name." + ) + return - if args.help: - raise ValueError("```\n" + parser.format_help() + "\n```") + db_session.add( + AOCRegistrations(aoc_userid=AOC_id, year=year, discord_userid=discord_id) + ) + db_session.commit() + db_session.close() - return args + await interaction.edit_original_response( + content=f"Advent of Code name `{aoc_name}` is now registered to {interaction.user.mention}." + ) - def get_leaderboard(self, year: int, code: int) -> Optional[Dict]: + @app_commands.checks.has_permissions(manage_guild=True) + @advent_command_group.command(name="register-force") + @app_commands.describe( + year="The year of Advent of Code this registration is for.", + discord_id_str="The discord ID number of the user. Note that this is not their username.", + aoc_name="The name shown on Advent of Code.", + aoc_id="The AOC id of the user.", + ) + async def register_admin_command( + self, + interaction: discord.Interaction, + year: int, + discord_id_str: str, # str as discord can't handle integers this big + aoc_name: Optional[str] = None, + aoc_id: Optional[int] = None, + ): """ - Returns a json dump of the leaderboard + Forces a registration entry to be created. For admin use only. Either aoc_name or aoc_id should be given. """ - try: - response = requests.get( - LEADERBOARD_URL.format(year=year, code=code), - cookies={"session": self.SESSION_ID}, + discord_id = int(discord_id_str) + if (aoc_name is None and aoc_id is None) or ( + aoc_name is not None and aoc_id is not None + ): + await interaction.response.send_message( + "Exactly one of `aoc_name` and `aoc_id` must be given.", ephemeral=True ) - return response.json() - except ValueError as exception: # json.JSONDecodeError - # TODO: Handle the case when the response is ok but the contents - # are invalid (cannot be parsed as json) - raise exception - except RequestException as exception: - logging.error(exception.response.content) - pass - return None - - @commands.command() - @loading_status - async def advent(self, ctx: commands.Context, *args): - """ - Prints the Advent of Code private leaderboard for UQCS. + return - !advent --help for additional help. - """ + await interaction.response.defer(thinking=True) - try: - args = self.parse_arguments(args) - except ValueError as error: - await ctx.send(str(error)) - return + db_session = self.bot.create_db_session() - try: - leaderboard = self.get_leaderboard(args.year, args.code) - except ValueError: - await ctx.send( - "Error fetching leaderboard data. Check the leaderboard code, year, and day." + if aoc_name: + members = self._get_members(year, force_refresh=True) + if aoc_name not in [member.name for member in members]: + await interaction.edit_original_response( + content=f"Could not find the Advent of Code name `{aoc_name}` within the UQCS leaderboard." + ) + return + member = [member for member in members if member.name == aoc_name] + if len(member) != 1: + await interaction.edit_original_response( + content=f"Could not find a unique Advent of Code name `{aoc_name}` within the UQCS leaderboard." + ) + member = member[0] + aoc_id = member.id + + query = ( + db_session.query(AOCRegistrations) + .filter( + and_( + AOCRegistrations.year == year, AOCRegistrations.aoc_userid == aoc_id + ) ) - raise - - try: - members = [ - Member.from_member_data(data, args.year, args.day) - for data in leaderboard["members"].values() - ] - except Exception: - await ctx.send("Error parsing leaderboard data.") - raise - - # whether to show only one day - is_day = bool(args.day) - # whether to use global points - is_global = args.global_ - - # header message - message = f":star: *Advent of Code Leaderboard {args.code}* :trophy:" - if is_day: - message += ( - f"\n:calendar: *Day {args.day}* (sorted by {SORT_LABELS[args.sort]})" - ) - elif is_global: - message += "\n:earth_asia: *Global Leaderboard Points*" - - scoreboardFile = io.StringIO( - self.format_advent_leaderboard(members, is_day, is_global, args.sort) + .one_or_none() ) - await ctx.send( - file=discord.File( - scoreboardFile, - filename=f"advent_{args.code}_{args.year}_{args.day}.txt", + if query is not None: + discord_user = self.bot.uqcs_server.get_member(query.discord_userid) + if discord_user: + discord_ping = discord_user.mention + else: + discord_ping = f"someone who doesn't seem to be in the server (discord id = {query.discord_userid})" + await interaction.edit_original_response( + content=f"Advent of Code name `{aoc_name}` is already registered to {discord_ping}." ) - ) + return - async def reminder_fifteen_minutes(self): - channel = discord.utils.get( - self.bot.uqcs_server.channels, name=self.CHANNEL_NAME + db_session.add( + AOCRegistrations(aoc_userid=aoc_id, year=year, discord_userid=discord_id) ) - if channel is not None: - await channel.send( - "Today's Advent of Code puzzle is released in 15 minutes." - ) + db_session.commit() + db_session.close() + + discord_user = self.bot.uqcs_server.get_member(discord_id) + if discord_user: + discord_ping = discord_user.mention else: - logging.warning(f"Could not find required channel #{self.CHANNEL_NAME}") + discord_ping = f"someone who doesn't seem to be in the server (discord id = {discord_id})" + await interaction.edit_original_response( + content=f"Advent of Code name `{aoc_name}` is now registered to {discord_ping} (for {year})." + ) - async def reminder_released(self): - channel = discord.utils.get( - self.bot.uqcs_server.channels, name=self.CHANNEL_NAME + @advent_command_group.command(name="unregister") + async def unregister_command(self, interaction: discord.Interaction): + """ + Remove your registration for Advent of code prizes. + """ + await interaction.response.defer(thinking=True) + + db_session = self.bot.create_db_session() + year = datetime.now().year + + discord_id = interaction.user.id + query = db_session.query(AOCRegistrations).filter( + and_( + AOCRegistrations.year == year, + AOCRegistrations.discord_userid == discord_id, + ) ) - if channel is not None: - await channel.send( - "Today's Advent of Code puzzle has been released. Good luck!" + if (query.one_or_none()) is None: + await interaction.edit_original_response( + content=f"Your discord account ({interaction.user.mention}) is already unregistered for this year." ) - else: - logging.warning(f"Could not find required channel #{self.CHANNEL_NAME}") + return - def _get_previous_winners(self, year: int): - db_session = self.bot.create_db_session() - prev_winners = db_session.query(AOCWinner).filter(AOCWinner.year == year) + query.delete(synchronize_session=False) + db_session.commit() db_session.close() - return [winner.aoc_userid for winner in prev_winners] + await interaction.edit_original_response( + content=f"{interaction.user.mention} is no longer registered to win Advent of Code prizes." + ) - def _add_winners(self, winners: List[Member], year: int): - db_session = self.bot.create_db_session() + @app_commands.checks.has_permissions(manage_guild=True) + @advent_command_group.command(name="unregister-force") + @app_commands.describe( + year="Year that the registration is for", + discord_id_str="The discord id to remove. Note that this is not the username.", + ) + async def unregister_admin_command( + self, interaction: discord.Interaction, year: int, discord_id_str: str + ): + """ + Forces a registration entry to be removed. + For admin use only; assumes you know what you are doing. + """ + discord_id = int(discord_id_str) + await interaction.response.defer(thinking=True) + discord_user = self.bot.uqcs_server.get_member(discord_id) - for winner in winners: - winner = AOCWinner(aoc_userid=winner.id, year=year) - db_session.add(winner) + db_session = self.bot.create_db_session() + query = db_session.query(AOCRegistrations).filter( + and_( + AOCRegistrations.year == year, + AOCRegistrations.discord_userid == discord_id, + ) + ) + if (query.one_or_none()) is None: + if discord_user: + discord_ping = discord_user.mention + else: + discord_ping = ( + f"who does not seem to be in the server; id = {discord_id}" + ) + await interaction.edit_original_response( + content=f"This discord account ({discord_ping}) is already unregistered for this year. Ensure that you enter the users discord id, not discord name or nickname." + ) + return + query.delete(synchronize_session=False) db_session.commit() db_session.close() - def random_choices_without_repition(self, population, weights, k): - result = [] - for _ in range(k): - if sum(weights) == 0: - return None + if discord_user: + discord_ping = discord_user.mention + else: + discord_ping = ( + f"A user who does not seem to be in the server (id = {discord_id})" + ) + await interaction.edit_original_response( + content=f"{discord_ping} is no longer registered to win Advent of Code prizes for {year}." + ) - result.append(choices(population, weights)[0]) - index = population.index(result[-1]) - population.pop(index) - weights.pop(index) + @advent_command_group.command(name="previous-winners") + @app_commands.describe( + year="Year to find the previous listed winners for. Defaults to the current year.", + show_ids="Whether to show the database ids. Mainly for debugging purposes. Defaults to false.", + ) + async def previous_winners_command( + self, + interaction: discord.Interaction, + year: Optional[int] = None, + show_ids: bool = False, + ): + """ + List the previous winners of Advent of Code. + """ + await interaction.response.defer(thinking=True) + if year is None: + year = datetime.now().year - return result + db_session = self.bot.create_db_session() + prev_winners = list( + db_session.query(AOCWinners).filter(AOCWinners.year == year) + ) + + if not prev_winners: + await interaction.edit_original_response( + content=f"No Advent of Code winners are on record for {year}." + ) + return + + registrations = self._get_registrations(year) + registered_AOC_ids = [member.aoc_userid for member in registrations] + + # TODO would an embed be appropriate? + message = f"UQCS Advent of Code winners for {year}:" + for winner in prev_winners: + message += f"\n{winner.id} " if show_ids else "\n" + + name = [ + member.name + for member in self._get_members(year) + if member.id == winner.aoc_userid + ] + # There are three types of user: + # 1) Those who are not on the downloaded members list from AOC (error case) + # 2) Those who have not linked a discord account + # 3) Those who have linked a discord account + if len(name) != 1: + message += f"Unknown User (AOC id {winner.aoc_userid}) - {winner.prize}" + elif winner.aoc_userid not in registered_AOC_ids: + message += f"{name[0]} (unregisted discord) - {winner.prize}" + else: + discord_user = self.bot.uqcs_server.get_member( + [user.discord_userid for user in registrations][0] + ) + discord_ping = f" ({discord_user.display_name})" if discord_user else "" + # Don't actually ping as this may be called many times + message += f"{name[0]}{discord_ping} - {winner.prize}" + db_session.commit() + db_session.close() - @commands.command() - @loading_status - async def advent_winners( - self, ctx: commands.Context, start: int, end: int, numberOfWinners: int, *args + await interaction.edit_original_response(content=message) + + @app_commands.checks.has_permissions(manage_guild=True) + @advent_command_group.command(name="add-winners") + @app_commands.describe( + prize="A description of the prize that is being awarded.", + start="The initial date (inclusive) to base the weights on. Defaults to 1.", + end="The final date (includive) to base the weights on. Defaults to 25.", + number_of_winners="The number of winners to select. Defaults to 1.", + weights='How to bias the winner selection. Defaults to "Equal"', + allow_repeat_winners="Allow for winners to be selected multiple times. Defaults to False", + allow_unregistered_users="Allow winners to be selected from unregistered users. Defaults to False.", + year="The year the prize is for. Defaults to the current year.", + aoc_id="The AOC id of the winner to add, if selecting a winner. Use only if manually selecting a winner.", + ) + async def add_winners_command( + self, + interaction: discord.Interaction, + prize: str, + start: int = 1, + end: int = 25, + number_of_winners: int = 1, + weights: Literal["Stars", "Equal"] = "Equal", + allow_repeat_winners: bool = False, + allow_unregistered_users: bool = False, + year: Optional[int] = None, + aoc_id: Optional[int] = None, ): """ - Determines winners for the AOC competition. Winners must be drawn by a member of the committee. - - !advent --help for additional help. + Randomly choose (or select) winners from those who have completed challenges. """ - if len([role for role in ctx.author.roles if role.name == "Committee"]) == 0: - await ctx.send("Only committee can select the winners") - return - try: - args = self.parse_arguments(args) - except ValueError as error: - await ctx.send(str(error)) - return + await interaction.response.defer(thinking=True) + if year is None: + year = datetime.now().year - try: - leaderboard = self.get_leaderboard(args.year, args.code) - except ValueError: - await ctx.send( - "Error fetching leaderboard data. Check the leaderboard code, year, and day." + if aoc_id: + self._add_winners( + [member for member in self._get_members(year) if member.id == aoc_id], + year, + prize, + ) + # Note that this message is a bit more dull, as it should only be used for admin reasons. + await interaction.edit_original_response( + content=f"The user with AOC id {aoc_id} has been recorded as winning a prize: {prize}" ) - raise + return - try: - members = [ - Member.from_member_data(data, args.year, args.day) - for data in leaderboard["members"].values() - ] - except Exception: - await ctx.send("Error parsing leaderboard data.") - raise + registrations = self._get_registrations(year) + registered_AOC_ids = [member.aoc_userid for member in registrations] - previous_winners = self._get_previous_winners(args.year) potential_winners = [ - member for member in members if int(member.id) not in previous_winners - ] - weights = [ - sum([1 for d in range(start, end + 1) if len(member.all_times[d]) > 0]) - for member in potential_winners + member + for member in self._get_members(year) + if any(member.attempted_day(day) for day in range(start, end + 1)) ] + if not allow_unregistered_users: + potential_winners = [ + member + for member in potential_winners + if member.id in registered_AOC_ids + ] - winners = self.random_choices_without_repition( - potential_winners, weights, numberOfWinners - ) + if allow_repeat_winners: + required_number_of_potential_winners = 1 + else: + required_number_of_potential_winners = number_of_winners - if winners == None: - await ctx.send( - f"Insufficient participants to be able to draw {numberOfWinners} winners." + if len(potential_winners) < required_number_of_potential_winners: + await interaction.edit_original_response( + content=f"There were not enough eligible users to select winners (at least {required_number_of_potential_winners} needed; only {len(potential_winners)} found)." ) return + number_of_potential_winners = len( + potential_winners + ) # potential winners will be changed ahead, so we store this value for the award message + + match weights: + case "Stars": + weight_values = [ + sum(len(member.times[day]) for day in range(start, end + 1)) + for member in potential_winners + ] + case "Equal": + weight_values = [1 for _ in potential_winners] + + if allow_repeat_winners: + winners = choices(potential_winners, weight_values, k=number_of_winners) + else: + winners = self._random_choices_without_repition( + potential_winners, weight_values, number_of_winners + ) - self._add_winners(winners, args.year) + if not winners: + await interaction.edit_original_response( + content="There was some problem choosing the winners." + ) + return - await ctx.send( - "And the winners are:\n" - + "\n".join( - [ - winner.name - if (winner.name != None) - else "anonymous user #" + str(winner.id) - for winner in winners - ] + self._add_winners(winners, year, prize) + + distinct_winners = set(winners) + + winners_message = "" + for i, winner in enumerate(distinct_winners): + discord_id = winner.get_discord_userid(self.bot) + discord_user = ( + self.bot.uqcs_server.get_member(discord_id) if discord_id else None + ) + discord_ping = f" ({discord_user.mention})" if discord_user else "" + number_of_prizes = len( + [member for member in winners if member.id == winner.id] + ) + prize_multiplier = f" (x{number_of_prizes})" if number_of_prizes > 1 else "" + winners_message += f"{winner.name}{discord_ping}{prize_multiplier}" + if len(distinct_winners) == 1: + pass + elif i < len(distinct_winners) - 1: + winners_message += ", " + else: + winners_message += " and " + + await interaction.edit_original_response( + content=f"The results are in! Out of {number_of_potential_winners} potential participants, {winners_message} have recieved a prize from participating in Advent of Code: {prize}", + allowed_mentions=discord.AllowedMentions( + everyone=False, users=True, roles=False + ), + ) + + @app_commands.checks.has_permissions(manage_guild=True) + @advent_command_group.command(name="remove-winner") + @app_commands.describe( + id="The database entry id for the winners database that should be deleted." + ) + async def remove_winner_command(self, interaction: discord.Interaction, id: int): + """ + Remove an AOC winner from the database. + The show_ids option for previous-winners can get the id. + """ + await interaction.response.defer(thinking=True) + + db_session = self.bot.create_db_session() + + query = db_session.query(AOCWinners).filter(AOCWinners.id == id) + if query.one_or_none() is None: + await interaction.response.send_message( + f"No Advent of Code winners could be found with a database id of {id}." ) + return + + query.delete(synchronize_session=False) + db_session.commit() + db_session.close() + + await interaction.edit_original_response( + content=f"Removed the winners entry with id {id}." ) diff --git a/uqcsbot/bot.py b/uqcsbot/bot.py index e2b045cf..bc01bd3d 100644 --- a/uqcsbot/bot.py +++ b/uqcsbot/bot.py @@ -29,6 +29,8 @@ def __init__(self, *args: Any, **kwargs: Any): # Important channel names & constants go here self.ADMIN_ALERTS_CNAME = "admin-alerts" self.GENERAL_CNAME = "general" + self.AOC_CNAME = "cpg" + self.AOC_ROLE = "cpg" self.BOT_TIMEZONE = timezone("Australia/Brisbane") self.uqcs_server: discord.Guild diff --git a/uqcsbot/course_ecp.py b/uqcsbot/course_ecp.py new file mode 100644 index 00000000..e00643b5 --- /dev/null +++ b/uqcsbot/course_ecp.py @@ -0,0 +1,110 @@ +from typing import Optional +import logging +from datetime import datetime +import discord +from discord import app_commands +from discord.ext import commands + +from uqcsbot.utils.uq_course_utils import ( + Offering, + HttpException, + CourseNotFoundException, + ProfileNotFoundException, + get_course_profile_url, +) +from uqcsbot.yelling import yelling_exemptor + + +class CourseECP(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + + @app_commands.command() + @app_commands.describe( + course1="The course to find an ECP for.", + course2="The second course to find an ECP for.", + course3="The third course to find an ECP for.", + course4="The fourth course to find an ECP for.", + year="The year to find the course ECP for. Defaults to what UQCSbot believes is the current year.", + semester="The semester to find the course ECP for. Defaults to what UQCSbot believes is the current semester.", + campus="The campus the course is held at. Defaults to St Lucia. Defaults to St Lucia. Note that many external courses are 'hosted' at St Lucia.", + mode="The mode of the course. Defaults to Internal.", + ) + @yelling_exemptor(input_args=["course1, course2, course3, course4"]) + async def courseecp( + self, + interaction: discord.Interaction, + course1: str, + course2: Optional[str], + course3: Optional[str], + course4: Optional[str], + year: Optional[int] = None, + semester: Optional[Offering.SemesterType] = None, + campus: Offering.CampusType = "St Lucia", + mode: Offering.ModeType = "Internal", + ): + """ + Returns the URL of the ECPs for course codes given. Assumes the same semester and year for the course codes given. + + """ + await interaction.response.defer(thinking=True) + + possible_courses = [course1, course2, course3, course4] + course_names = [c.upper() for c in possible_courses if c != None] + course_name_urls: dict[str, str] = {} + offering = Offering(semester=semester, campus=campus, mode=mode) + + try: + for course in course_names: + course_name_urls.update( + {course: get_course_profile_url(course, offering, year)} + ) + except HttpException as exception: + logging.warning( + f"Received a HTTP response code {exception.status_code} when trying find the course url using get_course_profile_url in course_ecp.py . Error information: {exception.message}" + ) + await interaction.edit_original_response( + content=f"Could not contact UQ, please try again." + ) + return + except (CourseNotFoundException, ProfileNotFoundException) as exception: + await interaction.edit_original_response(content=exception.message) + return + + # If year is none assign it the current year + if not year: + year = datetime.today().year + + # If semester is none assign it the current estimated semester + if not semester: + semester = Offering.estimate_current_semester() + + # Create the embedded message with the course names and details + embed = discord.Embed( + title=f"Course ECP: {', '.join(course_names)}", + description=f"For Semester {semester} {year}, {mode}, {campus}", + ) + + # Add the ECP urls to the embedded message + if course_name_urls: + for course in course_name_urls: + embed.add_field( + name=f"", + value=f"[{course}]({course_name_urls.get(course)}) ", + inline=False, + ) + else: + await interaction.edit_original_response( + content=f"No ECP could be found for the courses: {course_names}. The ECP(s) might not be available." + ) + return + + embed.set_footer( + text="The course ECP might be out of date, be sure to check the course on BlackBoard." + ) + await interaction.edit_original_response(embed=embed) + return + + +async def setup(bot: commands.Bot): + await bot.add_cog(CourseECP(bot)) diff --git a/uqcsbot/manage_cogs.py b/uqcsbot/manage_cogs.py index 116742ab..12bcef46 100644 --- a/uqcsbot/manage_cogs.py +++ b/uqcsbot/manage_cogs.py @@ -5,6 +5,7 @@ from discord.ext import commands from uqcsbot.yelling import yelling_exemptor +from uqcsbot.bot import UQCSBot class ManageCogs(commands.Cog): @@ -12,11 +13,11 @@ class ManageCogs(commands.Cog): Note that most of these commands can make the bot load files to execute. Care should be made to ensure only entrusted users have access. """ - def __init__(self, bot: commands.Bot): + def __init__(self, bot: UQCSBot): self.bot = bot @app_commands.command(name="managecogs") - @app_commands.default_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) @app_commands.describe( cog='The cog (i.e. python file) to try to unload. Use python package notation, so no suffix of ".py" and "." between folders: e.g. "manage_cogs".', ) @@ -49,5 +50,5 @@ async def manage_cogs( await self.bot.tree.sync() -async def setup(bot: commands.Bot): +async def setup(bot: UQCSBot): await bot.add_cog(ManageCogs(bot)) diff --git a/uqcsbot/minecraft.py b/uqcsbot/minecraft.py index 76c8a649..5c863dcc 100644 --- a/uqcsbot/minecraft.py +++ b/uqcsbot/minecraft.py @@ -4,18 +4,21 @@ import discord from aiomcrcon import Client, IncorrectPasswordError, RCONConnectionError # type: ignore +from mcstatus import JavaServer from discord import Member, app_commands, Colour from discord.ext import commands from uqcsbot.bot import UQCSBot from uqcsbot.models import MCWhitelist from uqcsbot.utils.err_log_utils import FatalErrorWithLog -from uqcsbot.yelling import yelling_exemptor RCON_ADDRESS = os.environ.get("MC_RCON_ADDRESS") RCON_PORT = os.environ.get("MC_RCON_PORT") RCON_PASSWORD = os.environ.get("MC_RCON_PASSWORD") +MC_PUBLIC_IP = os.environ.get("MC_PUBLIC_IP") +MC_PUBLIC_PORT = os.environ.get("MC_PUBLIC_PORT") + class Minecraft(commands.Cog): def __init__(self, bot: UQCSBot): @@ -48,9 +51,29 @@ async def send_rcon_command(self, command: str): return response + @app_commands.command() + async def mcplayers(self, interaction: discord.Interaction): + """Returns the number and list of people currently playing on the Minecraft server.""" + server = JavaServer.lookup( + f"{MC_PUBLIC_IP}:{MC_PUBLIC_PORT}" + ) # Does this need to be hard coded?? Is RCON addr/IP the same? + status = server.status() # type: ignore + + # Check if there are players online + # Not none so pyright shuts up + if status.players.online > 0 and status.players.sample is not None: + # Extract player names + player_names = [player.name for player in status.players.sample] + # Format the list of players + players_list = "\n".join(player_names) + response_message = f"The server has {status.players.online} player(s) online:\n```\n{players_list}\n```" + else: + response_message = f"The server has {status.players.online} players online." + + await interaction.response.send_message(response_message) + @app_commands.command() @app_commands.describe(username="Minecraft username to whitelist.") - @yelling_exemptor(input_args=["username"]) async def mcwhitelist(self, interaction: discord.Interaction, username: str): """Adds a username to the whitelist for the UQCS server.""" db_session = self.bot.create_db_session() @@ -93,6 +116,51 @@ async def mcwhitelist(self, interaction: discord.Interaction, username: str): db_session.close() + @app_commands.command() + @app_commands.describe(username="Minecraft username to unwhitelist.") + async def mcunwhitelist(self, interaction: discord.Interaction, username: str): + """Removes a username from the whitelist for the UQCS server.""" + db_session = self.bot.create_db_session() + is_user_admin = ( + isinstance(interaction.user, Member) + and interaction.user.guild_permissions.manage_guild + ) + + # If the user has already whitelisted someone, and they aren't an admin deny it. + if not is_user_admin: + await interaction.response.send_message( + "You've already whitelisted an account." + ) + else: + # Send the RCON command to remove the user from the whitelist + response_remove = await self.send_rcon_command( + f"whitelist remove {username}" + ) + logging.info(f"[MINECRAFT] whitelist remove {username}: {response_remove}") + + # Send the RCON command to kick the player from the server + response_kick = await self.send_rcon_command(f"kick {username}") + logging.info(f"[MINECRAFT] kick {username}: {response_kick}") + + # If the responses indicate successful removal, remove from the database item + if "Removed" in response_remove[0]: + db_session.query(MCWhitelist).filter( + MCWhitelist.mc_username == username + ).delete() + db_session.commit() + + await self.bot.admin_alert( + title="Minecraft Server Unwhitelist", + description=response_remove[0], + footer=f"Action performed by {interaction.user}", + colour=Colour.red(), + ) + + # Display the response to the user in Discord + await interaction.response.send_message(response_remove[0]) + + db_session.close() + mcadmin_group = app_commands.Group( name="mcadmin", description="Commands for managing the UQCS Minecraft server" ) diff --git a/uqcsbot/models.py b/uqcsbot/models.py index 44a5dd2a..87744b74 100644 --- a/uqcsbot/models.py +++ b/uqcsbot/models.py @@ -16,10 +16,23 @@ class Base(DeclarativeBase): pass -class AOCWinner(Base): - __tablename__ = "aoc_winner" +class AOCWinners(Base): + __tablename__ = "aoc_winners" - id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, nullable=False) + id: Mapped[int] = mapped_column( + "id", Integer, primary_key=True, nullable=False, autoincrement=True + ) + aoc_userid: Mapped[int] = mapped_column("aoc_userid", Integer, nullable=False) + year: Mapped[int] = mapped_column("year", Integer, nullable=False) + prize: Mapped[str] = mapped_column("prize", String, nullable=True) + + +class AOCRegistrations(Base): + __tablename__ = "aoc_registrations" + + discord_userid: Mapped[int] = mapped_column( + "discord_userid", BigInteger, primary_key=True, nullable=False + ) aoc_userid: Mapped[int] = mapped_column("aoc_userid", Integer, nullable=False) year: Mapped[int] = mapped_column("year", Integer, nullable=False) diff --git a/uqcsbot/starboard.py b/uqcsbot/starboard.py index ed727d18..7614bd74 100644 --- a/uqcsbot/starboard.py +++ b/uqcsbot/starboard.py @@ -62,7 +62,7 @@ async def on_ready(self): ) @app_commands.command() - @app_commands.default_permissions(manage_guild=True) + @app_commands.checks.has_permissions(manage_guild=True) async def cleanup_starboard(self, interaction: discord.Interaction): """Cleans up the last 100 messages from the starboard. Removes any uqcsbot message that doesn't have a corresponding message id in the db, regardless of recv. @@ -70,7 +70,6 @@ async def cleanup_starboard(self, interaction: discord.Interaction): manage_guild perms: for committee and infra use. """ - if interaction.channel == self.starboard_channel: # because if you do it from in the starboard, it deletes its own interaction response # and i cba making it not do that, so i'll just forbid doing it in starboard. @@ -146,7 +145,7 @@ async def _blacklist_log( ) ) - @app_commands.default_permissions(manage_messages=True) + @app_commands.checks.has_permissions(manage_messages=True) async def context_blacklist_sb_message( self, interaction: discord.Interaction, message: discord.Message ): @@ -194,7 +193,7 @@ async def context_blacklist_sb_message( f"Blacklisted message {message.id}.", ephemeral=True ) - @app_commands.default_permissions(manage_messages=True) + @app_commands.checks.has_permissions(manage_messages=True) async def context_unblacklist_sb_message( self, interaction: discord.Interaction, message: discord.Message ): diff --git a/uqcsbot/utils/advent_utils.py b/uqcsbot/utils/advent_utils.py new file mode 100644 index 00000000..7d3ba168 --- /dev/null +++ b/uqcsbot/utils/advent_utils.py @@ -0,0 +1,386 @@ +from typing import Any, List, Literal, Dict, Optional, Callable +from datetime import datetime, timedelta +from pytz import timezone + +from uqcsbot.bot import UQCSBot +from uqcsbot.models import AOCRegistrations + +# Days in Advent of Code. List of numbers 1 to 25. +ADVENT_DAYS = list(range(1, 25 + 1)) + +# type aliases for documentation purposes. +Day = int # from 1 to 25 +Star = Literal[1, 2] +Seconds = int +Times = Dict[Star, Seconds] +Delta = Optional[Seconds] +Json = Dict[str, Any] + +# Puzzles are unlocked at midnight EST. +EST_TIMEZONE = timezone("US/Eastern") + +# The time to cache results to limit requests to adventofcode.com. Note that 15 minutes is the recomended minimum time. +CACHE_TIME = timedelta(minutes=15) + + +class InvalidHTTPSCode(Exception): + def __init__(self, message: str, request_code: int): + super().__init__(message) + self.request_code = request_code + + +class Member: + def __init__(self, id: int, name: str, local: int, star_total: int, global_: int): + # The advent of code id + self.id = id + # The advent of code name + self.name = name if name else "Anon" + # The score of the user on the local leaderboard + self.local = local + # The total number of stars the user has collected + self.star_total = star_total + # The score of the user on the global leaderboard + self.global_ = global_ + + # All of the Times. If no stars are collected, the Times dictionary is empty. + self.times: Dict[Day, Times] = {d: {} for d in ADVENT_DAYS} + + @classmethod + def from_member_data(cls, data: Json, year: int) -> "Member": + """ + Constructs a Member from the API response. + + Times and delta are calculated for the given year and day. + """ + + member = cls( + data["id"], + data["name"], + data["local_score"], + data["stars"], + data["global_score"], + ) + + for d, day_data in data["completion_day_level"].items(): + day = int(d) + times = member.times[day] + + # timestamp of puzzle unlock, rounded to whole seconds + DAY_START = int(datetime(year, 12, day, tzinfo=EST_TIMEZONE).timestamp()) + + for s, star_data in day_data.items(): + star = int(s) + # assert is for type checking + assert star == 1 or star == 2 + times[star] = int(star_data["get_star_ts"]) - DAY_START + assert times[star] >= 0 + + return member + + def get_time_delta(self, day: Day) -> Optional[Seconds]: + """ + Returns the number of seconds between the completion of the second star from the first, or None if the second star have not been completed. + """ + if len(self.times[day]) == 2: + return self.times[day][2] - self.times[day][1] + return None + + def attempted_day(self, day: Day) -> bool: + """ + Returns if a member completed at least the first star in the day + """ + return len(self.times[day]) >= 1 + + def get_total_star1_time(self, default: int = 0) -> int: + """ + Returns the total time working on just star 1 for all challenges in a year. + The argument default determines the returned value if the total is 0. + """ + total = sum(self.times[day].get(1, 0) for day in ADVENT_DAYS) + return total if total != 0 else default + + def get_total_star2_time(self, default: int = 0) -> int: + """ + Returns the total time working on just star 2 for all challenges in a year. + The argument default determines the returned value if the total is 0. + """ + total = sum(self.times[day].get(2, 0) for day in ADVENT_DAYS) + return total if total != 0 else default + + def get_total_time(self, default: int = 0) -> int: + """ + Returns the total time working on stars 1 and 2 for all challenges in a year. + The argument default determines the returned value if the total is 0. + """ + total = self.get_total_star1_time() + self.get_total_star2_time() + return total if total != 0 else default + + def get_discord_userid(self, bot: UQCSBot) -> Optional[int]: + """ + Return the discord userid of this AOC member if one is registered in the database. + """ + db_session = bot.create_db_session() + registration = ( + db_session.query(AOCRegistrations) + .filter(AOCRegistrations.aoc_userid == self.id) + .one_or_none() + ) + db_session.close() + if registration: + return registration.discord_userid + return None + + +def _star_char(num_stars: int): + """ + Given a number of stars (0, 1, or 2), returns its leaderboard + representation. + """ + return " .*"[num_stars] + + +def _format_seconds(seconds: Optional[int]): + """ + Format seconds into the format "hh:mm:ss" or ">24h". + """ + if seconds is None or seconds == 0: + return "" + delta = timedelta(seconds=seconds) + if delta > timedelta(hours=24): + return ">24h" + return str(delta) + + +def _format_seconds_long(seconds: Optional[int]): + """ + Format seconds into the format "hhhh:mm:ss" or ">30 days". + """ + if seconds is None or seconds == 0: + return "-" + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours >= 30 * 24: + return ">30 days" + return f"{hours}:{minutes:02}:{seconds:02}" + + +def _get_member_star_progress_bar(member: Member): + return "".join(_star_char(len(member.times[day])) for day in ADVENT_DAYS) + + +class LeaderboardColumn: + """ + A column in a leaderboard. The title is the name of the column as 2 lines and the calculation is a function that determines what is printed for a given member, index and day. The title and calculation should have the same constant width. + """ + + def __init__( + self, + title: tuple[str, str], + calculation: Callable[[Member, int, Optional[Day]], str], + ): + self.title = title + self.calculation = calculation + + @staticmethod + def ordering_column(): + """ + A column used at the right of leaderboards to indicate the overall order. Of the format "XXX)" where XXX is a left padded number of 3 characters. + """ + return LeaderboardColumn( + title=(" " * 4, " " * 4), # Empty spaces, as this does not need a heading + calculation=lambda _, index, __: f"{index:>3})", + ) + + @staticmethod + def star1_column(): + """ + A column indicating the time taken to achieve the first star. Of the format "hh:mm:ss" or ">24h". Only applicable for particular days. + """ + return LeaderboardColumn( + title=(" " * 8, " Star 1 "), + calculation=lambda member, _, day: f"{_format_seconds(member.times[day].get(1, 0)) if day else '':>8}", + ) + + @staticmethod + def star2_column(): + """ + A column indicating the time taken to achieve only the second star. Of the format "hh:mm:ss" or ">24h". Only applicable for particular days. + """ + return LeaderboardColumn( + title=(" " * 8, " Star 2 "), + calculation=lambda member, _, day: f"{_format_seconds(member.get_time_delta(day)) if day else '':>8}", + ) + + @staticmethod + def star1_and_2_column(): + """ + A column indicating the time taken to achieve both stars. Of the format "hh:mm:ss" or ">24h". Only applicable for particular days. + """ + return LeaderboardColumn( + title=(" " * 10, "Both Stars"), + calculation=lambda member, _, day: f"{_format_seconds(member.times[day].get(2, 0)) if day else '':>10}", + ) + + @staticmethod + def total_time_column(): + """ + A column indicating the total time the user has spent on all stars. Of the format "hhhh:mm:ss" or ">30 days". + """ + return LeaderboardColumn( + title=(" " * 10, "Total Time"), + calculation=lambda member, _, __: f"{_format_seconds_long(member.get_total_time()):>10}", + ) + + @staticmethod + def total_star1_time_column(): + """ + A column indicating the total time the user has spent on first stars. Of the format "hhhh:mm:ss" or ">30 days". + """ + return LeaderboardColumn( + title=("Total Star", " 1 Time "), + calculation=lambda member, _, __: f"{_format_seconds_long(member.get_total_star1_time()):>10}", + ) + + @staticmethod + def total_star2_time_column(): + """ + A column indicating the total time the user has spent on second stars. Of the format "hhhh:mm:ss" or ">30 days". + """ + return LeaderboardColumn( + title=("Total Star", " 2 Time "), + calculation=lambda member, _, __: f"{_format_seconds_long(member.get_total_star2_time()):>10}", + ) + + @staticmethod + def stars_column(): + """ + A column indicating the total number of stars a user has. Of the format of a 5 character right-padded number. + """ + return LeaderboardColumn( + title=("Total", "Stars"), + calculation=lambda member, _, __: f"{member.star_total if member.star_total else '':>5}", + ) + + @staticmethod + def local_rank_column(): + """ + A column indicating the members local rank (of the UQCS leaderboard). Of the format of a 5 character right-padded number. + """ + return LeaderboardColumn( + title=("Local", "Order"), + calculation=lambda member, _, __: f"{member.local if member.local else '':>5}", + ) + + @staticmethod + def global_score_column(): + """ + A column indicating the members global score. Of the format of a 5 character right-padded number. + """ + return LeaderboardColumn( + title=("Global", "Score "), + calculation=lambda member, _, __: f"{member.global_ if member.global_ else '':>6}", + ) + + @staticmethod + def star_bar_column(): + """ + A column with a progressbar of the stars that each person has. + """ + return LeaderboardColumn( + title=(" " * 9 + "1" * 10 + "2" * 6, "1234567890123456789012345"), + calculation=lambda member, _, __: _get_member_star_progress_bar(member), + ) + + @staticmethod + def name_column(bot: UQCSBot): + """ + A column listing each name. + """ + + def format_name(member: Member, _: int, __: Optional[int]) -> str: + if not (discord_userid := member.get_discord_userid(bot)): + return member.name + if not (discord_user := bot.uqcs_server.get_member(discord_userid)): + return member.name + # Don't actually ping as leaderboard is called many times + return f"{member.name} (@{discord_user.display_name})" + + return LeaderboardColumn(title=("", ""), calculation=format_name) + + @staticmethod + def padding_column(): + """ + A column that is of a single space character. + """ + return LeaderboardColumn(title=(" ", " "), calculation=lambda _, __, ___: " ") + + +def parse_leaderboard_column_string(s: str, bot: UQCSBot) -> List[LeaderboardColumn]: + """ + Create a list of columns corresponding to the given string. The characters in the string can be: + # - Provides a column of the form "XXX)" telling the order for the given leaderboard + 1 - The time for star 1 for the specific day (daily leaderboards only) + 2 - The time for star 2 for the specific day (daily leaderboards only) + 3 - The time for both stars for the specific day (dayly leaderboards only) + ! - The total time spent on first stars for the whole competition + @ - The total time spent on second stars for the whole competition + T - The total time spent overall for the whole competition + * - The total number of stars for the whole competition + L - The local ranking someone has within the UQCS leaderboard + G - The global score someone has + B - A progress bar of the stars each person has + space - A padding column of a single character + All other characters will be ignored + """ + columns: List[LeaderboardColumn] = [] + for c in s: + match c: + case "#": + columns.append(LeaderboardColumn.ordering_column()) + case "1": + columns.append(LeaderboardColumn.star1_column()) + case "2": + columns.append(LeaderboardColumn.star2_column()) + case "3": + columns.append(LeaderboardColumn.star1_and_2_column()) + case "!": + columns.append(LeaderboardColumn.total_star1_time_column()) + case "@": + columns.append(LeaderboardColumn.total_star2_time_column()) + case "T": + columns.append(LeaderboardColumn.total_time_column()) + case "*": + columns.append(LeaderboardColumn.stars_column()) + case "L": + columns.append(LeaderboardColumn.local_rank_column()) + case "G": + columns.append(LeaderboardColumn.global_score_column()) + case "B": + columns.append(LeaderboardColumn.star_bar_column()) + case " ": + columns.append(LeaderboardColumn.padding_column()) + case _: + pass + columns.append(LeaderboardColumn.padding_column()) + columns.append(LeaderboardColumn.name_column(bot)) + return columns + + +def print_leaderboard( + columns: List[LeaderboardColumn], members: List[Member], day: Optional[Day] +): + """ + Returns a string of the leaderboard of the given format. + """ + leaderboard = "".join(column.title[0] for column in columns) + leaderboard += "\n" + leaderboard += "".join(column.title[1] for column in columns) + + # Note that leaderboards start at 1, not 0 + for id, member in enumerate(members, start=1): + leaderboard += "\n" + leaderboard += "".join( + column.calculation(member, id, day) for column in columns + ) + + return leaderboard diff --git a/uqcsbot/utils/uq_course_utils.py b/uqcsbot/utils/uq_course_utils.py index a4c2d9db..f879d790 100644 --- a/uqcsbot/utils/uq_course_utils.py +++ b/uqcsbot/utils/uq_course_utils.py @@ -3,9 +3,10 @@ from datetime import datetime from dateutil import parser from bs4 import BeautifulSoup, element -from functools import partial from typing import List, Dict, Optional, Literal, Tuple +from dataclasses import dataclass import json +import re BASE_COURSE_URL = "https://my.uq.edu.au/programs-courses/course.html?course_code=" BASE_ASSESSMENT_URL = ( @@ -13,8 +14,10 @@ "student_section_report.php?report=assessment&profileIds=" ) BASE_CALENDAR_URL = "http://www.uq.edu.au/events/calendar_view.php?category_id=16&year=" -OFFERING_PARAMETER = "offer" BASE_PAST_EXAMS_URL = "https://api.library.uq.edu.au/v1/exams/search/" +# Parameters for the course page +OFFERING_PARAMETER = "offer" +YEAR_PARAMETER = "year" class Offering: @@ -58,7 +61,7 @@ def __init__( if semester is not None: self.semester = semester else: - self.semester = self._estimate_current_semester() + self.semester = self.estimate_current_semester() self.semester self.campus = campus self.mode = mode @@ -92,7 +95,7 @@ def get_offering_code(self) -> str: return offering_code_text.encode("utf-8").hex() @staticmethod - def _estimate_current_semester() -> SemesterType: + def estimate_current_semester() -> SemesterType: """ Returns an estimate of the current semester (represented by an integer) based on the current month. 3 represents summer semester. """ @@ -105,6 +108,69 @@ def _estimate_current_semester() -> SemesterType: return "Summer" +@dataclass +class AssessmentItem: + course_name: str + task: str + due_date: str + weight: str + + def get_parsed_due_date(self): + """ + Returns the parsed due date for the given assessment item as a datetime + object. If the date cannot be parsed, a DateSyntaxException is raised. + """ + if self.due_date == "Examination Period": + return get_current_exam_period() + parser_info = parser.parserinfo(dayfirst=True) + try: + # If a date range is detected, attempt to split into start and end + # dates. Else, attempt to just parse the whole thing. + if " - " in self.due_date: + start_date, end_date = self.due_date.split(" - ", 1) + start_datetime = parser.parse(start_date, parser_info) + end_datetime = parser.parse(end_date, parser_info) + return start_datetime, end_datetime + due_datetime = parser.parse(self.due_date, parser_info) + return due_datetime, due_datetime + except Exception: + raise DateSyntaxException(self.due_date, self.course_name) + + def is_after(self, cutoff: datetime): + """ + Returns whether the assessment occurs after the given cutoff. + """ + try: + start_datetime, end_datetime = self.get_parsed_due_date() + except DateSyntaxException: + # If we can't parse a date, we're better off keeping it just in case. + return True + return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff + + def is_before(self, cutoff: datetime): + """ + Returns whether the assessment occurs before the given cutoff. + """ + try: + start_datetime, _ = self.get_parsed_due_date() + except DateSyntaxException: + # TODO bot.logger.error(e.message) + # If we can't parse a date, we're better off keeping it just in case. + # TODO(mitch): Keep track of these instances to attempt to accurately + # parse them in future. Will require manual detection + parsing. + return True + return start_datetime <= cutoff + + def get_weight_as_int(self) -> Optional[int]: + """ + Trys to get the weight percentage of an assessment as a percentage. Will return None + if a percentage can not be obtained. + """ + if match := re.match(r"\d+", self.weight): + return int(match.group(0)) + return None + + class DateSyntaxException(Exception): """ Raised when an unparsable date syntax is encountered. @@ -192,23 +258,19 @@ def get_uq_request( def get_course_profile_url( - course_name: str, offering: Optional[Offering] = None + course_name: str, + offering: Optional[Offering] = None, + year: Optional[int] = None, ) -> str: """ - Returns the URL to the course profile for the given course for a given offering. - If no offering is give, will return the first course profile on the course page. + Returns the URL to the course profile (ECP) for the given course for a given offering. + If no offering or year are given, the first course profile on the course page will be returned. """ - if offering is None: - course_url = BASE_COURSE_URL + course_name - else: - course_url = ( - BASE_COURSE_URL - + course_name - + "&" - + OFFERING_PARAMETER - + "=" - + offering.get_offering_code() - ) + course_url = BASE_COURSE_URL + course_name + if offering: + course_url += "&" + OFFERING_PARAMETER + "=" + offering.get_offering_code() + if year: + course_url += "&" + YEAR_PARAMETER + "=" + str(year) http_response = get_uq_request(course_url) if http_response.status_code != requests.codes.ok: @@ -234,14 +296,14 @@ def get_course_profile_url( return url -def get_course_profile_id(course_name: str, offering: Optional[Offering]): +def get_course_profile_id(course_name: str, offering: Optional[Offering] = None) -> int: """ Returns the ID to the latest course profile for the given course. """ profile_url = get_course_profile_url(course_name, offering=offering) # The profile url looks like this # https://course-profiles.uq.edu.au/student_section_loader/section_1/100728 - return profile_url[profile_url.rindex("/") + 1 :] + return int(profile_url[profile_url.rindex("/") + 1 :]) def get_current_exam_period(): @@ -270,44 +332,6 @@ def get_current_exam_period(): return start_datetime, end_datetime -def get_parsed_assessment_due_date(assessment_item: Tuple[str, str, str, str]): - """ - Returns the parsed due date for the given assessment item as a datetime - object. If the date cannot be parsed, a DateSyntaxException is raised. - """ - course_name, _, due_date, _ = assessment_item - if due_date == "Examination Period": - return get_current_exam_period() - parser_info = parser.parserinfo(dayfirst=True) - try: - # If a date range is detected, attempt to split into start and end - # dates. Else, attempt to just parse the whole thing. - if " - " in due_date: - start_date, end_date = due_date.split(" - ", 1) - start_datetime = parser.parse(start_date, parser_info) - end_datetime = parser.parse(end_date, parser_info) - return start_datetime, end_datetime - due_datetime = parser.parse(due_date, parser_info) - return due_datetime, due_datetime - except Exception: - raise DateSyntaxException(due_date, course_name) - - -def is_assessment_after_cutoff(assessment: Tuple[str, str, str, str], cutoff: datetime): - """ - Returns whether the assessment occurs after the given cutoff. - """ - try: - start_datetime, end_datetime = get_parsed_assessment_due_date(assessment) - except DateSyntaxException: - # TODO bot.logger.error(e.message) - # If we can't parse a date, we're better off keeping it just in case. - # TODO(mitch): Keep track of these instances to attempt to accurately - # parse them in future. Will require manual detection + parsing. - return True - return end_datetime >= cutoff if end_datetime else start_datetime >= cutoff - - def get_course_assessment_page( course_names: List[str], offering: Optional[Offering] ) -> str: @@ -316,17 +340,18 @@ def get_course_assessment_page( url to the assessment table for the provided courses """ profile_ids = map( - lambda course: get_course_profile_id(course, offering=offering), course_names + lambda course: str(get_course_profile_id(course, offering=offering)), + course_names, ) return BASE_ASSESSMENT_URL + ",".join(profile_ids) def get_course_assessment( course_names: List[str], - cutoff: Optional[datetime] = None, + cutoff: Tuple[Optional[datetime], Optional[datetime]] = (None, None), assessment_url: Optional[str] = None, offering: Optional[Offering] = None, -) -> List[Tuple[str, str, str, str]]: +) -> List[AssessmentItem]: """ Returns all the course assessment for the given courses that occur after the given cutoff. @@ -346,9 +371,12 @@ def get_course_assessment( assessment = assessment_table.findAll("tr")[1:] parsed_assessment = map(get_parsed_assessment_item, assessment) # If no cutoff is specified, set cutoff to UNIX epoch (i.e. filter nothing). - cutoff = cutoff or datetime.min - assessment_filter = partial(is_assessment_after_cutoff, cutoff=cutoff) - filtered_assessment = filter(assessment_filter, parsed_assessment) + cutoff_min = cutoff[0] or datetime.min + cutoff_max = cutoff[1] or datetime.max + filtered_assessment = filter( + lambda item: item.is_after(cutoff_min) and item.is_before(cutoff_max), + parsed_assessment, + ) return list(filtered_assessment) @@ -360,8 +388,8 @@ def get_element_inner_html(dom_element: element.Tag): def get_parsed_assessment_item( - assessment_item: element.Tag, -) -> Tuple[str, str, str, str]: + assessment_item_tag: element.Tag, +) -> AssessmentItem: """ Returns the parsed assessment details for the given assessment item table row element. @@ -371,7 +399,7 @@ def get_parsed_assessment_item( This is likely insufficient to handle every course's structure, and thus is subject to change. """ - course_name, task, due_date, weight = assessment_item.findAll("div") + course_name, task, due_date, weight = assessment_item_tag.findAll("div") # Handles courses of the form 'CSSE1001 - Sem 1 2018 - St Lucia - Internal'. # Thus, this bit of code will extract the course. course_name = course_name.text.strip().split(" - ")[0] @@ -384,7 +412,7 @@ def get_parsed_assessment_item( # Handles weights of the form '30%
Alternative to oral presentation'. # Thus, this bit of code will keep only the weight portion of the field. weight = get_element_inner_html(weight).strip().split("
")[0] - return (course_name, task, due_date, weight) + return AssessmentItem(course_name, task, due_date, weight) class Exam: diff --git a/uqcsbot/whatsdue.py b/uqcsbot/whatsdue.py index 9dd61c68..7b3a51cb 100644 --- a/uqcsbot/whatsdue.py +++ b/uqcsbot/whatsdue.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timedelta import logging -from typing import Optional +from typing import Optional, Callable, Literal, Dict import discord from discord import app_commands @@ -9,14 +9,39 @@ from uqcsbot.yelling import yelling_exemptor from uqcsbot.utils.uq_course_utils import ( + DateSyntaxException, Offering, CourseNotFoundException, HttpException, ProfileNotFoundException, + AssessmentItem, get_course_assessment, get_course_assessment_page, + get_course_profile_id, ) +AssessmentSortType = Literal["Date", "Course Name", "Weight"] +ECP_ASSESSMENT_URL = ( + "https://course-profiles.uq.edu.au/student_section_loader/section_5/" +) + + +def sort_by_date(item: AssessmentItem): + """Provides a key to sort assessment dates by. If the date cannot be parsed, will put it with items occuring during exam block.""" + try: + return item.get_parsed_due_date()[0] + except DateSyntaxException: + return datetime.max + + +SORT_METHODS: Dict[ + AssessmentSortType, Callable[[AssessmentItem], int | str | datetime] +] = { + "Date": sort_by_date, + "Course Name": (lambda item: item.course_name), + "Weight": (lambda item: item.get_weight_as_int() or 0), +} + class WhatsDue(commands.Cog): def __init__(self, bot: commands.Bot): @@ -26,15 +51,14 @@ def __init__(self, bot: commands.Bot): @app_commands.describe( fulloutput="Display the full list of assessment. Defaults to False, which only " + "shows assessment due from today onwards.", + weeks_to_show="Only show assessment due within this number of weeks. If 0 (default), show all assessment.", semester="The semester to get assessment for. Defaults to what UQCSbot believes is the current semester.", campus="The campus the course is held at. Defaults to St Lucia. Note that many external courses are 'hosted' at St Lucia.", mode="The mode of the course. Defaults to Internal.", - course1="Course code", - course2="Course code", - course3="Course code", - course4="Course code", - course5="Course code", - course6="Course code", + courses="Course codes seperated by spaces", + sort_order="The order to sort courses by. Defualts to Date.", + reverse_sort="Whether to reverse the sort order. Defaults to false.", + show_ecp_links="Show the first ECP link for each course page. Defaults to false.", ) @yelling_exemptor( input_args=["course1", "course2", "course3", "course4", "course5", "course6"] @@ -42,16 +66,15 @@ def __init__(self, bot: commands.Bot): async def whatsdue( self, interaction: discord.Interaction, - course1: str, - course2: Optional[str], - course3: Optional[str], - course4: Optional[str], - course5: Optional[str], - course6: Optional[str], + courses: str, fulloutput: bool = False, + weeks_to_show: int = 0, semester: Optional[Offering.SemesterType] = None, campus: Offering.CampusType = "St Lucia", mode: Offering.ModeType = "Internal", + sort_order: AssessmentSortType = "Date", + reverse_sort: bool = False, + show_ecp_links: bool = False, ): """ Returns all the assessment for a given list of course codes that are scheduled to occur. @@ -60,15 +83,19 @@ async def whatsdue( await interaction.response.defer(thinking=True) - possible_courses = [course1, course2, course3, course4, course5, course6] - course_names = [c.upper() for c in possible_courses if c != None] + course_names = [c.upper() for c in courses.split()] offering = Offering(semester=semester, campus=campus, mode=mode) # If full output is not specified, set the cutoff to today's date. - cutoff = None if fulloutput else datetime.today() + cutoff = ( + None if fulloutput else datetime.today(), + datetime.today() + timedelta(weeks=weeks_to_show) + if weeks_to_show > 0 + else None, + ) try: - asses_page = get_course_assessment_page(course_names, offering) - assessment = get_course_assessment(course_names, cutoff, asses_page) + assessment_page = get_course_assessment_page(course_names, offering) + assessment = get_course_assessment(course_names, cutoff, assessment_page) except HttpException as e: logging.error(e.message) await interaction.edit_original_response( @@ -81,15 +108,15 @@ async def whatsdue( embed = discord.Embed( title=f"What's Due: {', '.join(course_names)}", - url=asses_page, + url=assessment_page, description="*WARNING: Assessment information may vary/change/be entirely different! Use at your own discretion. Check your ECP for a true list of assessment.*", ) if assessment: + assessment.sort(key=SORT_METHODS[sort_order], reverse=reverse_sort) for assessment_item in assessment: - course, task, due, weight = assessment_item embed.add_field( - name=course, - value=f"`{weight}` {task} **({due})**", + name=assessment_item.course_name, + value=f"`{assessment_item.weight}` {assessment_item.task} **({assessment_item.due_date})**", inline=False, ) elif fulloutput: @@ -103,6 +130,18 @@ async def whatsdue( value=f"Nothing seems to be due soon", ) + if show_ecp_links: + ecp_links = [ + f"[{course_name}]({ECP_ASSESSMENT_URL + str(get_course_profile_id(course_name))})" + for course_name in course_names + ] + embed.add_field( + name=f"Potential ECP {'Link' if len(course_names) == 1 else 'Links'}", + value=" ".join(ecp_links) + + "\nNote that these may not be the correct ECPs. Check the year and offering type.", + inline=False, + ) + if not fulloutput: embed.set_footer( text="Note: This may not be the full assessment list. Set fulloutput to True to see a potentially more complete list, or check your ECP for a true list of assessment." diff --git a/uqcsbot/yelling.py b/uqcsbot/yelling.py index abbe005a..5a6ee04c 100644 --- a/uqcsbot/yelling.py +++ b/uqcsbot/yelling.py @@ -3,8 +3,6 @@ from discord.ext import commands from random import choice, random import re -from urllib.request import urlopen -from urllib.error import URLError from uqcsbot.bot import UQCSBot from uqcsbot.cog import UQCSBotCog @@ -48,7 +46,8 @@ async def wrapper( if not Yelling.contains_lowercase(text): await func(cogself, *args, **kwargs) return - await interaction.response.send_message( + + await interaction.response.send_message( # type: ignore str(discord.utils.get(bot.emojis, name="disapproval") or "") ) if isinstance(interaction.user, discord.Member): @@ -167,12 +166,6 @@ def clean_text(self, message: str) -> str: # slightly more permissive version of discord's url regex, matches absolutely anything between http(s):// and whitespace for url in re.findall(r"https?:\/\/[^\s]+", text, flags=re.UNICODE): - try: - resp = urlopen(url) - except (ValueError, URLError): - continue - if 400 <= resp.code <= 499: - continue text = text.replace(url, url.upper()) text = text.replace(">", ">").replace("<", "<").replace("&", "&")