diff --git a/.gitignore b/.gitignore
index ca24ce3..0034ff7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ venv/
 docs/build
 dist/
 .vscode/
+/*.py
\ No newline at end of file
diff --git a/poetry.lock b/poetry.lock
index 4d4d80c..55e2f15 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -147,6 +147,17 @@ files = [
     {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
 ]
 
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+description = "Reusable constraint types to use with typing.Annotated"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
+    {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
+]
+
 [[package]]
 name = "attrs"
 version = "24.2.0"
@@ -580,6 +591,160 @@ MarkupSafe = ">=2.0"
 [package.extras]
 i18n = ["Babel (>=2.7)"]
 
+[[package]]
+name = "lxml"
+version = "5.3.0"
+description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656"},
+    {file = "lxml-5.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8"},
+    {file = "lxml-5.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32"},
+    {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86"},
+    {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5"},
+    {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03"},
+    {file = "lxml-5.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7"},
+    {file = "lxml-5.3.0-cp310-cp310-win32.whl", hash = "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80"},
+    {file = "lxml-5.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3"},
+    {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b"},
+    {file = "lxml-5.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080"},
+    {file = "lxml-5.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654"},
+    {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d"},
+    {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763"},
+    {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec"},
+    {file = "lxml-5.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be"},
+    {file = "lxml-5.3.0-cp311-cp311-win32.whl", hash = "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9"},
+    {file = "lxml-5.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1"},
+    {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859"},
+    {file = "lxml-5.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c"},
+    {file = "lxml-5.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99"},
+    {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff"},
+    {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a"},
+    {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8"},
+    {file = "lxml-5.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d"},
+    {file = "lxml-5.3.0-cp312-cp312-win32.whl", hash = "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30"},
+    {file = "lxml-5.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f"},
+    {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a"},
+    {file = "lxml-5.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367"},
+    {file = "lxml-5.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832"},
+    {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff"},
+    {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd"},
+    {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb"},
+    {file = "lxml-5.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b"},
+    {file = "lxml-5.3.0-cp313-cp313-win32.whl", hash = "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957"},
+    {file = "lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d"},
+    {file = "lxml-5.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e"},
+    {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f"},
+    {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94"},
+    {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99"},
+    {file = "lxml-5.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237"},
+    {file = "lxml-5.3.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577"},
+    {file = "lxml-5.3.0-cp36-cp36m-win32.whl", hash = "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70"},
+    {file = "lxml-5.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c"},
+    {file = "lxml-5.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033"},
+    {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391"},
+    {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6"},
+    {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d"},
+    {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512"},
+    {file = "lxml-5.3.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b"},
+    {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5"},
+    {file = "lxml-5.3.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11"},
+    {file = "lxml-5.3.0-cp37-cp37m-win32.whl", hash = "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84"},
+    {file = "lxml-5.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e"},
+    {file = "lxml-5.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753"},
+    {file = "lxml-5.3.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040"},
+    {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22"},
+    {file = "lxml-5.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22"},
+    {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15"},
+    {file = "lxml-5.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920"},
+    {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945"},
+    {file = "lxml-5.3.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42"},
+    {file = "lxml-5.3.0-cp38-cp38-win32.whl", hash = "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e"},
+    {file = "lxml-5.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903"},
+    {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de"},
+    {file = "lxml-5.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe"},
+    {file = "lxml-5.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2"},
+    {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71"},
+    {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3"},
+    {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727"},
+    {file = "lxml-5.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a"},
+    {file = "lxml-5.3.0-cp39-cp39-win32.whl", hash = "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff"},
+    {file = "lxml-5.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83"},
+    {file = "lxml-5.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f"},
+    {file = "lxml-5.3.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8"},
+    {file = "lxml-5.3.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9"},
+    {file = "lxml-5.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c"},
+    {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"},
+]
+
+[package.extras]
+cssselect = ["cssselect (>=0.7)"]
+html-clean = ["lxml-html-clean"]
+html5 = ["html5lib"]
+htmlsoup = ["BeautifulSoup4"]
+source = ["Cython (>=3.0.11)"]
+
 [[package]]
 name = "markupsafe"
 version = "3.0.1"
@@ -917,6 +1082,130 @@ files = [
     {file = "propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70"},
 ]
 
+[[package]]
+name = "pydantic"
+version = "2.9.2"
+description = "Data validation using Python type hints"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"},
+    {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"},
+]
+
+[package.dependencies]
+annotated-types = ">=0.6.0"
+pydantic-core = "2.23.4"
+typing-extensions = [
+    {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
+    {version = ">=4.6.1", markers = "python_version < \"3.13\""},
+]
+
+[package.extras]
+email = ["email-validator (>=2.0.0)"]
+timezone = ["tzdata"]
+
+[[package]]
+name = "pydantic-core"
+version = "2.23.4"
+description = "Core functionality for Pydantic validation and serialization"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"},
+    {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"},
+    {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"},
+    {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"},
+    {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"},
+    {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"},
+    {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"},
+    {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"},
+    {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"},
+    {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"},
+    {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"},
+    {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"},
+    {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"},
+    {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"},
+    {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"},
+    {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"},
+    {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"},
+    {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"},
+    {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"},
+    {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"},
+    {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"},
+    {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
+
 [[package]]
 name = "pygments"
 version = "2.18.0"
@@ -1146,6 +1435,17 @@ files = [
     {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
 ]
 
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+    {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
 [[package]]
 name = "urllib3"
 version = "2.2.3"
@@ -1356,4 +1656,4 @@ propcache = ">=0.2.0"
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.11.0,<4.0.0"
-content-hash = "88f7e9a802c9a6c2aaae5160975c8b3eba88b622de02de57b4a1fccd1948ad45"
+content-hash = "a6507929bd5ca310fdccfb1a4f781e77c1fe4ae831aeac158c5cf539dbb611fd"
diff --git a/pyproject.toml b/pyproject.toml
index ec30c12..a226ee0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,9 @@ platformdirs = "^3.0.0"
 websockets = "^10.4"
 aiohttp = "^3.8.3"
 backoff = "^2.2.1"
+lxml = "^5.3.0"
+yarl = "^1.15.2"
+pydantic = "^2.9.2"
 
 [tool.poetry.dev-dependencies]
 # Remember to update docs/build-requirements.txt!
@@ -23,6 +26,9 @@ tomli = "^2.0.1"
 [tool.poetry.group.dev.dependencies]
 black = {version = "^23.1.0", allow-prereleases = true}
 
+[tool.isort]
+profile = "black"
+
 [build-system]
 requires = ["poetry-core>=1.0.0"]
 build-backend = "poetry.core.masonry.api"
diff --git a/sechat/__init__.py b/sechat/__init__.py
index 53b7c09..f81944f 100644
--- a/sechat/__init__.py
+++ b/sechat/__init__.py
@@ -1,7 +1,6 @@
-from sechat.server import Server
-from sechat.bot import Bot
+from sechat.credentials import Credentials
 from sechat.room import Room
-from sechat.version import __version__
-from sechat.events import Event, EventType, MentionEvent, MessageEvent, UnknownEvent, EditEvent, ReplyEvent, DeleteEvent
+from sechat.servers import Server
 
-__all__ = ["Server", "Bot", "Room", "Event", "EventType", "MentionEvent", "MessageEvent", "UnknownEvent", "EditEvent", "ReplyEvent", "DeleteEvent"]
\ No newline at end of file
+__version__ = "2.0.0"
+__all__ = ["Credentials", "Room", "Server"]
diff --git a/sechat/bot.py b/sechat/bot.py
deleted file mode 100644
index a2238b4..0000000
--- a/sechat/bot.py
+++ /dev/null
@@ -1,235 +0,0 @@
-from typing import Optional, cast
-from logging import Logger, getLogger
-from pathlib import Path
-from os import PathLike, makedirs
-from asyncio import create_task, Task
-from functools import partial
-
-from platformdirs import user_cache_path
-from aiohttp import ClientSession, CookieJar
-from bs4 import BeautifulSoup, Tag
-from hashlib import md5
-
-from sechat.server import Server
-from sechat.room import Room
-from sechat.errors import LoginError
-from sechat.version import __version__
-
-class Bot:
-    def __init__(
-        self,
-        server: Server = Server.STACK_EXCHANGE,
-        useCookies: bool = True,
-        logger: Optional[Logger] = None,
-        cachePath: Optional[PathLike] = None,
-    ):
-        self.server = server
-        self.useCookies = useCookies
-        if logger:
-            self.logger = logger
-        else:
-            self.logger = getLogger("Bot")
-        if cachePath:
-            self.cachePath = Path(cachePath)
-        else:
-            self.cachePath = user_cache_path("sechat", None, __version__)
-        makedirs(self.cachePath, exist_ok=True)
-
-        self.cookieJar = CookieJar()
-        self.roomTasks: dict[Room, Task] = {}
-        self.rooms: dict[int, Room] = {}
-
-    def loadCookies(self, email: str, cookies: CookieJar):
-        cookiePath = (
-            self.cachePath
-            / f"sechat_cookies_{md5(email.encode('utf-8')).hexdigest()}.dat"
-        )
-        try:
-            cookies.load(cookiePath)
-        except FileNotFoundError:
-            self.logger.debug("No cookies found :(")
-        else:
-            return True
-
-    def dumpCookies(self, email: str, cookies: CookieJar):
-        cookiePath = (
-            self.cachePath
-            / f"sechat_cookies_{md5(email.encode('utf-8')).hexdigest()}.dat"
-        )
-        cookies.save(cookiePath)
-        self.logger.debug(f"Dumped cookies to {cookiePath}")
-
-    async def getChatFkey(self, session: ClientSession) -> Optional[str]:
-        async with session.get(
-            f"https://{self.server}/chats/join/favorite"
-        ) as response:
-            soup = BeautifulSoup(
-                await response.text(),
-                "html.parser",
-            )
-            if content := soup.find(id="content"):
-                if form := cast(Tag, content).form:
-                    if fkeyInput := form.find("input", attrs={"name": "fkey"}):
-                        if fkey := cast(Tag, fkeyInput).get("value"):
-                            if isinstance(fkey, list):
-                                return "".join(fkey)
-                            return fkey
-
-    async def getChatUserId(self, session: ClientSession) -> Optional[int]:
-        async with session.get(
-            f"https://{self.server}/chats/join/favorite"
-        ) as response:
-            soup = BeautifulSoup(
-                await response.text(),
-                "html.parser",
-            )
-            if links := soup.find(class_="topbar-menu-links"):
-                if link := cast(Tag, links).find("a"):
-                    if href := cast(Tag, link).get("href"):
-                        if isinstance(href, list):
-                            href = "".join(href)
-                        return int(href.split("/")[2])
-
-    async def scrapeFkey(self, session: ClientSession) -> Optional[str]:
-        async with session.get(
-            "https://meta.stackexchange.com/users/login"
-        ) as response:
-            soup = BeautifulSoup(
-                await response.text(),
-                "html.parser",
-            )
-            if fkeyTag := soup.find(attrs={"name": "fkey"}):
-                fkey = cast(Tag, fkeyTag)["value"]
-                if isinstance(fkey, list):
-                    return "".join(fkey)
-                return fkey
-
-    async def doSELogin(
-        self, session: ClientSession, host: str, email: str, password: str, fkey: str
-    ) -> str:
-        async with session.post(
-            f"{host}/users/login-or-signup/validation/track",
-            data={
-                "email": email,
-                "password": password,
-                "fkey": fkey,
-                "isSignup": "false",
-                "isLogin": "true",
-                "isPassword": "false",
-                "isAddLogin": "false",
-                "hasCaptcha": "false",
-                "ssrc": "head",
-                "submitButton": "Log in",
-            },
-        ) as response:
-            return await response.text()
-
-    async def loadProfile(
-        self, session: ClientSession, host: str, fkey: str, email: str, password: str
-    ):
-        async with session.post(
-            f"{host}/users/login",
-            params={"ssrc": "head", "returnurl": f"{host}"},
-            data={
-                "email": email,
-                "password": password,
-                "fkey": fkey,
-                "ssrc": "head",
-            },
-        ) as response:
-            soup = BeautifulSoup(
-                await response.text(),
-                "html.parser",
-            )
-            if head := soup.head:
-                if title := head.title:
-                    if titleString := title.string:
-                        if "Human verification" in titleString:
-                            raise LoginError(
-                                "Failed to load SE profile: Caught by captcha. (It's almost like I'm not human!) Wait around 5min and try again."
-                            )
-                        else:
-                            return
-            raise LoginError(
-                "Failed to load SE profile: Unable to ascertain success state."
-            )
-
-    async def universalLogin(self, session: ClientSession, host: str):
-        return await session.post(f"{host}/users/login/universal/request")
-
-    def needsToLogin(self, email: str) -> bool:
-        if self.useCookies:
-            if self.loadCookies(email, self.cookieJar):
-                self.cookieJar._do_expiration()
-                return "acct" not in self.cookieJar._cookies.get(
-                    ("stackexchange.com", "/"), {}
-                )
-        return True
-
-    async def authenticate(self, email: str, password: Optional[str], host: str):
-        if self.useCookies:
-            if self.loadCookies(email, self.cookieJar):
-                self.logger.debug("Loaded cookies")
-        self.cookieJar._do_expiration()
-        async with ClientSession() as session:
-            session.headers.update(
-                {
-                    "User-Agent": f"Mozilla/5.0 (compatible; sechat/{__version__}; +http://pypi.org/project/sechat)"
-                }
-            )
-            if "acct" not in self.cookieJar._cookies.get(("stackexchange.com", "/"), {}):
-                assert password is not None, "Cookie expired, must supply password!"
-                self.logger.debug("Logging into SE...")
-                self.logger.debug("Acquiring fkey...")
-                fkey = await self.scrapeFkey(session)
-                if not fkey:
-                    raise LoginError("Failed to scrape site fkey.")
-                self.logger.debug(f"Acquired fkey: {fkey}")
-                self.logger.info(f"Logging into {host}...")
-                result = await self.doSELogin(session, host, fkey, email, password)
-                if result != "Login-OK":
-                    raise LoginError(f"Site login failed!", result)
-                self.logger.debug(f"Logged into {host}!")
-                self.logger.debug("Loading profile...")
-                await self.loadProfile(session, host, fkey, email, password)
-                self.logger.debug("Loaded SE profile!")
-                self.logger.debug("Logging into the rest of the network...")
-                await self.universalLogin(session, host)
-                if self.useCookies:
-                    self.logger.debug("Dumping cookies...")
-                    self.dumpCookies(email, self.cookieJar)
-
-            self.fkey = await self.getChatFkey(session)
-            self.userID = await self.getChatUserId(session)
-            if not self.fkey or not self.userID:
-                raise LoginError("Login failed. Bad email/password?")
-            self.logger.debug(f"Chat fkey is {self.fkey}, user ID is {self.userID}")
-            self.logger.info(f"Logged into {host}!")
-
-    def _roomExited(self, room: Room, task: Task):
-        if (e := task.exception()) != None:
-            self.logger.critical(
-                f"An exception occured in the task of room {room.roomID}", exc_info=e
-            )
-
-    async def joinRoom(self, roomID: int, logger: Optional[Logger] = None) -> Room:
-        self.logger.info(f"Joining room {roomID}")
-        if not self.fkey or not self.userID:
-            raise RuntimeError("Not logged in")
-        assert roomID not in self.rooms, "Already in room"
-        room = Room(self.server, self.cookieJar, self.fkey, self.userID, roomID, logger)
-        task = create_task(room.loop(), name=room.logger.name)
-        task.add_done_callback(partial(self._roomExited, room))
-        self.rooms[roomID] = room
-        self.roomTasks[room] = task
-        await room._connectedEvent.wait()
-        return room
-
-    async def closeRoom(self, roomID: int):
-        self.logger.info(f"Leaving room {roomID}")
-        task = self.roomTasks.pop(self.rooms.pop(roomID))
-        task.cancel()
-        await task
-
-    async def closeAllRooms(self):
-        [await self.closeRoom(room) for room in list(self.rooms.keys())]
diff --git a/sechat/credentials.py b/sechat/credentials.py
new file mode 100644
index 0000000..bb4ac3e
--- /dev/null
+++ b/sechat/credentials.py
@@ -0,0 +1,189 @@
+import pickle
+from dataclasses import dataclass
+from http.cookies import Morsel
+from logging import getLogger
+from os.path import exists
+from typing import TYPE_CHECKING, cast
+
+from aiohttp import ClientSession, CookieJar
+from bs4 import BeautifulSoup, Tag
+from yarl import URL
+
+from sechat.errors import LoginError
+from sechat.servers import Server
+
+if TYPE_CHECKING:
+    from _typeshed import FileDescriptorOrPath
+
+LOGIN_HOST = URL("https://meta.stackexchange.com")
+COOKIE_ROOT = "stackexchange.com"
+USER_AGENT = "Mozilla/5.0 (compatible; sechat/2.0.0; +https://pypi.org/project/sechat)"
+logger = getLogger(__name__)
+
+
+@dataclass
+class Credentials:
+    server: Server
+    acct: Morsel[str]
+    prov: Morsel[str]
+    chatusr: Morsel[str]
+    user_id: int
+
+    @property
+    def cookies(self):
+        return {
+            "acct": self.acct,
+            "prov": self.prov,
+            (
+                "sechatusr" if self.server == Server.STACK_EXCHANGE else "chatusr"
+            ): self.chatusr,
+        }
+
+    @property
+    def headers(self):
+        return {"User-Agent": USER_AGENT, "Referer": self.server}
+
+    def session(self):
+        return ClientSession(self.server, cookies=self.cookies, headers=self.headers)
+
+    @staticmethod
+    async def authenticate(
+        email: str, password: str, *, server: Server = Server.META_STACK_EXCHANGE
+    ) -> "Credentials":
+        logger.info(f"Logging into {server}")
+        chat_user_cookie = "sechatusr" if server == Server.STACK_EXCHANGE else "chatusr"
+
+        async with ClientSession(
+            LOGIN_HOST, headers={"User-Agent": USER_AGENT}
+        ) as qa_session:
+            async with qa_session.get("/users/login") as response:
+                response.raise_for_status()
+                soup = BeautifulSoup(await response.read(), "lxml")
+                assert isinstance(login_form := soup.find(id="login-form"), Tag)
+                assert isinstance(
+                    fkey_input := login_form.find(attrs={"name": "fkey"}), Tag
+                )
+                assert isinstance(qa_fkey := fkey_input["value"], str)
+                logger.debug(f"QA fkey is {qa_fkey}")
+            async with qa_session.post(
+                "/users/login-or-signup/validation/track",
+                data={
+                    "isSignup": "false",
+                    "isLogin": "true",
+                    "isPassword": "false",
+                    "isAddLogin": "false",
+                    "fkey": qa_fkey,
+                    "ssrc": "head",
+                    "email": email,
+                    "password": password,
+                    "oauthversion": "",
+                    "oauthserver": "",
+                },
+            ) as response:
+                if response.status != 200:
+                    raise LoginError(
+                        "Failed to login!", response.status, await response.text()
+                    )
+            async with qa_session.post(
+                "/users/login",
+                data={
+                    "fkey": qa_fkey,
+                    "ssrc": "login",
+                    "email": email,
+                    "password": password,
+                    "oauth_version": "",
+                    "oauth_server": "",
+                },
+                allow_redirects=False,
+            ) as response:
+                if response.status != 302:
+                    raise LoginError("Login failed! Incorrect username or password?")
+                if (redirect_target := URL(response.headers["Location"])).path != "/":
+                    raise LoginError(
+                        f"Login failed! Redirected to {redirect_target}; caught by captcha?"
+                    )
+                logger.debug(f"Logged in to {LOGIN_HOST}")
+
+        qa_cookies = cast(CookieJar, qa_session.cookie_jar)._cookies[(COOKIE_ROOT, "")]
+        acct = qa_cookies["acct"]
+        prov = qa_cookies["prov"]
+
+        async with ClientSession(
+            server, headers={"User-Agent": USER_AGENT}
+        ) as chat_session:
+            chat_session.cookie_jar.update_cookies(
+                cookies={"acct": acct, "prov": prov},
+                response_url=URL.build(scheme="https", host=COOKIE_ROOT),
+            )
+            async with chat_session.get("/") as response:
+                response.raise_for_status()
+                if chat_user_cookie not in response.cookies:
+                    raise LoginError(
+                        f"Login failed! {chat_user_cookie} not in cookies returned from {server}"
+                    )
+                chatusr = response.cookies[chat_user_cookie]
+
+                soup = BeautifulSoup(await response.read(), "lxml")
+                assert isinstance(
+                    topbar_menu_links := soup.find(class_="topbar-menu-links"), Tag
+                )
+                assert isinstance(profile_link := topbar_menu_links.find("a"), Tag)
+                assert isinstance(url := profile_link["href"], str)
+                url = URL(url)
+                if url.host == "stackexchange.com":
+                    raise LoginError(
+                        "The supplied credentials were not accepted by chat! Bad username or password?"
+                    )
+                user_id = int(url.parts[2])
+
+        logger.info(f"Logged into QA and chat")
+        return Credentials(
+            server=server, acct=acct, prov=prov, chatusr=chatusr, user_id=user_id
+        )
+
+    @staticmethod
+    async def load(path: "FileDescriptorOrPath"):
+        logger.info(f"Reading credentials from {path}")
+        with open(path, "rb") as file:
+            credentials = pickle.load(file)
+        assert isinstance(credentials, Credentials)
+        async with credentials.session() as session, session.get("/") as response:
+            soup = BeautifulSoup(await response.read(), "lxml")
+            assert isinstance(
+                topbar_menu_links := soup.find(class_="topbar-menu-links"), Tag
+            )
+            assert isinstance(profile_link := topbar_menu_links.find("a"), Tag)
+            assert isinstance(url := profile_link["href"], str)
+            url = URL(url)
+            if url.host == "stackexchange.com":
+                logger.info(f"Credentials in {path} are expired!")
+                return None
+            user_id = int(url.parts[2])
+            if user_id != credentials.user_id:
+                logger.warning(
+                    f"Credentials in {path} have an incorrect user id! ({user_id} expected, {credentials.user_id} loaded)"
+                )
+                return None
+        return credentials
+
+    @staticmethod
+    async def load_or_authenticate(
+        path: "FileDescriptorOrPath",
+        email: str,
+        password: str,
+        *,
+        server: Server = Server.STACK_EXCHANGE,
+    ):
+        if exists(path):
+            try:
+                credentials = await Credentials.load(path)
+            except Exception as e:
+                logger.error(f"Failed to load credentials from {path}", exc_info=e)
+            else:
+                if credentials is not None:
+                    return credentials
+        credentials = await Credentials.authenticate(email, password, server=server)
+        with open(path, "wb") as file:
+            pickle.dump(credentials, file)
+        logger.info(f"Saved credentials to {file}")
+        return credentials
diff --git a/sechat/events.py b/sechat/events.py
index 9c04237..b759259 100644
--- a/sechat/events.py
+++ b/sechat/events.py
@@ -1,11 +1,10 @@
-from typing import Any, Optional
-from enum import Enum
-from dataclasses import dataclass, field, InitVar
-from datetime import datetime
-from collections import defaultdict
-from html import unescape
-
-class EventType(Enum):
+from typing import Annotated, Any, Literal, Optional
+from enum import IntEnum
+
+from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
+
+
+class EventType(IntEnum):
     MESSAGE = 1
     EDIT = 2
     JOIN = 3
@@ -33,81 +32,53 @@ class EventType(Enum):
     USER_NAME_OR_AVATAR_CHANGE = 34
 
 
-class EventBase:
-    pass
-
-
-class UnknownEvent(EventBase):
-    def __init__(self, **kwargs):
-        self.eventType = EventType(kwargs["event_type"])
-        self.args = kwargs
-
-
-@dataclass
-class Event(EventBase):
-    event_type: InitVar[int]
-    time_stamp: InitVar[int]
+class Event(BaseModel):
     id: int
-    timestamp: datetime = field(init=False)
-
-    def __post_init__(self, event_type, time_stamp):
-        self.eventType = EventType(event_type)
-        self.timestamp = datetime.fromtimestamp(time_stamp)
-
-
-@dataclass
-class RoomEvent(Event):
     room_id: int
-    room_name: int
+    room_name: str
 
 
-@dataclass
-class MessageEvent(RoomEvent):
-    content: str
+class BaseMessageEvent(Event):
     message_id: int
     user_id: int
     user_name: str
+
     parent_id: Optional[int] = None
     show_parent: Any = None  # idfk what this is
     target_user_id: int = 0
+
     message_stars: int = 0
     message_owner_stars: int = 0
     message_edits: int = 0
 
-    def __post_init__(self, event_type, time_stamp):
-        super().__post_init__(event_type, time_stamp)
-        self.content = unescape(self.content)
+
+class DeleteEvent(BaseMessageEvent):
+    event_type: Literal[EventType.DELETE]
+
+
+class MessageEvent(BaseMessageEvent):
+    event_type: Literal[EventType.MESSAGE]
+    content: str
 
 
-@dataclass
 class EditEvent(MessageEvent):
-    pass
+    event_type: Literal[EventType.EDIT]
+
 
-@dataclass
 class MentionEvent(MessageEvent):
-    pass
-    
-@dataclass
+    event_type: Literal[EventType.MENTION]
+
+
 class ReplyEvent(MessageEvent):
-    pass
-    
-@dataclass
-class DeleteEvent(RoomEvent):
-    user_id: int
-    user_name: int
-    message_id: int
-    message_edits: int = 0
-    target_user_id: Optional[int] = None
-    parent_id: Optional[int] = None
-    show_parent: bool = False
-
-EVENT_CLASSES = defaultdict(
-    lambda: UnknownEvent,
-    {
-        EventType.MESSAGE: MessageEvent,
-        EventType.MENTION: MentionEvent,
-        EventType.REPLY: ReplyEvent,
-        EventType.EDIT: EditEvent,
-        EventType.DELETE: DeleteEvent,
-    },
+    event_type: Literal[EventType.REPLY]
+
+
+class UnknownEvent(Event):
+    event_type: EventType
+    model_config = ConfigDict(extra="allow")
+
+
+Events = DeleteEvent | MessageEvent | EditEvent | MentionEvent | ReplyEvent
+_EventAdapter = TypeAdapter[Event](
+    Annotated[Events, Field(discriminator="event_type")] | UnknownEvent
 )
diff --git a/sechat/room.py b/sechat/room.py
index 260e6bd..8429dd3 100644
--- a/sechat/room.py
+++ b/sechat/room.py
@@ -1,370 +1,168 @@
-from typing import Optional, Any, TypeVar
-from collections.abc import Callable, Coroutine, Collection
-from time import monotonic, time
-from logging import Logger, getLogger
-from asyncio import gather, sleep, wait_for, CancelledError, Event
-
 import json
 import re
 
-from websockets.client import connect
-from websockets.exceptions import ConnectionClosed
-from aiohttp import ClientConnectionError, ClientSession, CookieJar
+from functools import partialmethod
+from logging import getLogger
+from time import monotonic, time
+from typing import Any, AsyncGenerator, Optional, cast
 
-from backoff import on_exception as backoff, runtime
+from aiohttp import ClientSession
+from backoff import on_exception, runtime
+from bs4 import BeautifulSoup, Tag
+from yarl import URL
 
-from sechat.server import Server
-from sechat.events import EventBase, MentionEvent, EventType, EVENT_CLASSES
-from sechat.errors import RatelimitError, OperationFailedError
-from sechat.version import __version__
+from sechat.credentials import Credentials
+from sechat.errors import OperationFailedError, RatelimitError
+from sechat.events import Event, _EventAdapter, MentionEvent, ReplyEvent
 
-T = TypeVar("T", bound=EventBase)
-EventHandler = Callable[["Room", T], Coroutine]
+RESET_INTERVAL = 60 * 60 * 2
+BACKOFF_RESPONSE = re.compile(r"You can perform this action again in (\d+) seconds?\.")
 
 
 class Room:
-    def __init__(
-        self,
-        server: Server,
-        cookies: CookieJar,
-        fkey: str,
-        userID: int,
-        roomID: int,
-        logger: Optional[Logger] = None,
-    ):
-        if logger:
-            self.logger = logger
-        else:
-            self.logger = getLogger(f"Room-{roomID}")
-        self._connectedEvent = Event()
-        self.server = server
-        self.cookies = cookies
+    class join:
+        def __init__(self, credentials: Credentials, room_id: int):
+            self.credentials = credentials
+            self.room_id = room_id
+
+        async def __aenter__(self):
+            self.session = self.credentials.session()
+            async with self.session.get("/chats/join/favorite") as response:
+                soup = BeautifulSoup(await response.read(), "lxml")
+                assert isinstance(fkey_input := soup.find(id="fkey"), Tag)
+                assert isinstance(fkey := fkey_input.attrs["value"], str)
+            self.room = Room(self.room_id, self.session, fkey)
+            return self.room
+
+        async def __aexit__(self, *args):
+            await self.room.close()
+
+    def __init__(self, room_id: int, session: ClientSession, fkey: str):
+        self.logger = getLogger(__name__).getChild(str(room_id))
+        self.room_id = room_id
+        self.session = session
         self.fkey = fkey
-        self.userID = userID
-        self.roomID = roomID
-        self.lastPing = time()
-        self.handlers: dict[EventType, set[EventHandler]] = {
-            eventType: set() for eventType in EventType
-        }
-        self.register(self._mentionHandler, EventType.MENTION)
 
-    def __hash__(self):
-        return hash(self.roomID)
-
-    async def shutdown(self):
-        self.logger.info("Shutting down...")
-        try:
-            await wait_for(
-                self.request(
-                    f"https://{self.server}/chats/leave/{self.roomID}"
-                ),
-                3,
-            )
-            await wait_for(self.session.close(), 3)
-        except TimeoutError:
-            pass
-        self.logger.debug("Shutdown completed!")
+    async def close(self):
+        await self._request(f"/chats/leave/{self.room_id}", {"quiet": "true"})
+        await self.session.close()
 
-    async def _mentionHandler(self, _, event: MentionEvent):
-        try:
-            await self.session.post(
-                f"https://{self.server}/messages/ack",
-                data={"id": event.message_id, "fkey": self.fkey},
-            )
-        except:
-            pass
-
-    async def getSocketUrls(self):
+    async def _socket_urls(self):
         while True:
-            try:
-                async with self.session.post(
-                    f"https://{self.server}/ws-auth",
-                    data={"fkey": self.fkey, "roomid": self.roomID},
-                ) as r:
-                    url = (await r.json())["url"] + f"?l={int(time())}"
-                    self.logger.info(f"Connecting to {url}")
-                    yield url
-            except ClientConnectionError:
-                self.logger.warning(
-                    "An error occured while fetching the socket, trying again in 3s"
-                )
-                await sleep(3)
-
-    async def loop(self):
-        self.session = ClientSession(cookie_jar=self.cookies)
-        self.session.headers.update(
-            {
-                "User-Agent": f"Mozilla/5.0 (compatible; automated;) sechat/{__version__} (logged in as user {self.userID}; +http://pypi.org/project/sechat)"
-            }
-        )
-        try:
-            async for url in self.getSocketUrls():
-                async with connect(url, origin=f"http://{self.server}", close_timeout=3, ping_interval=None) as socket:  # type: ignore It doesn't like the origin header for some reason
-                    self._connectedEvent.set()
-                    self.logger.info("Connected!")
-                    connectedAt = monotonic()
+            async with self.session.post(
+                "/ws-auth", data={"fkey": self.fkey, "roomid": self.room_id}
+            ) as response:
+                response.raise_for_status()
+                url = URL((await response.json())["url"]).with_query(l=int(time()))
+            yield url
+
+    async def events(self) -> AsyncGenerator[Event, None]:
+        async with ClientSession(
+            headers=self.session.headers, cookie_jar=self.session.cookie_jar
+        ) as ws_session:
+            async for url in self._socket_urls():
+                async with ws_session.ws_connect(
+                    url, origin=str(self.session._base_url)
+                ) as connection:
+                    self.logger.info(f"Connected to {url}, fkey is {self.fkey}")
+                    connected_at = monotonic()
                     while True:
+                        if monotonic() - connected_at >= RESET_INTERVAL:
+                            self.logger.debug("Resetting socket after reset interval")
+                            break
                         try:
-                            data = await wait_for(socket.recv(), timeout=45)
-                        except ConnectionClosed:
-                            self.logger.warning(
-                                "Connection was closed. Attempting to reconnect..."
+                            message = cast(
+                                dict, await connection.receive_json(timeout=45)
                             )
-                            break
-                        except TimeoutError:
+                        except Exception as e:
                             self.logger.warning(
-                                "No data recieved in a while, the connection may have dropped. Attempting to reconnect..."
+                                "An exception occured while receiving data:", exc_info=e
                             )
                             break
-                        except CancelledError:
-                            raise
-                        except Exception:
-                            self.logger.critical(
-                                "An error occurred while recieving data!"
-                            )
-                            raise
-                        if data is not None and data != "":
-                            try:
-                                data = json.loads(data)
-                            except (json.JSONDecodeError, TypeError):
-                                self.logger.warning(
-                                    f"Recieved malformed packet: {data}"
-                                )
-                                continue
-                            await self.process(data)
-                        if monotonic() - connectedAt > 60 * 60 * 2:
-                            self.logger.info(f"Connected for 2 hours, resetting socket")
-                            break
-        finally:
-            await self.shutdown()
-
-    async def process(self, data: dict):
-        if f"r{self.roomID}" in data:
-            data = data[f"r{self.roomID}"]
-            if data != {}:
-                if "e" in data:
-                    for event in data["e"]:
-                        if not isinstance(event, dict):
-                            continue
-                        self.logger.debug(f"Got event data: {event}")
-                        for result in await gather(
-                            *[
-                                i
-                                async for i in self.handle(
-                                    EventType(event["event_type"]), event
-                                )
-                            ],
-                            return_exceptions=True,
+                        if (
+                            (body := message.get(f"r{self.room_id}")) is not None
+                            and body != {}
+                            and (events := body.get("e")) is not None
                         ):
-                            if isinstance(result, Exception):
-                                self.logger.error(
-                                    f"An exception occured in a handler:",
-                                    exc_info=result,
+                            for event_data in events:
+                                self.logger.debug(
+                                    f"Recieved event data: {event_data!r}"
                                 )
-
-    async def handle(self, eventType: EventType, eventData: dict):
-        event = EVENT_CLASSES[eventType](**eventData)
-        for handler in self.handlers[eventType]:
-            try:
-                yield handler(self, event)
-            except Exception:
-                self.logger.exception(
-                    f"Exception occured in handler for {eventType.name}"
-                )
-
-    def register(self, handler: EventHandler, eventType: EventType):
-        self.handlers[eventType].add(handler)
-
-    def unregister(self, handler: EventHandler, eventType: EventType):
-        self.handlers[eventType].remove(handler)
-
-    def on(self, eventType: EventType) -> Callable[[EventHandler], EventHandler]:
-        def _on(handler: EventHandler):
-            self.register(handler, eventType)
-            return handler
-
-        return _on
-
-    @backoff(runtime, RatelimitError, value=lambda e: e.retryAfter, jitter=None)
-    async def request(self, uri: str, data: dict[str, Any] = {}):
-        while True:
-            try:
-                response = await self.session.post(
-                    uri,
-                    data=data | {"fkey": self.fkey},
-                    headers={
-                        "Referer": f"https://{self.server}/rooms/{self.roomID}"
-                    },
-                )
-            except ClientConnectionError:
-                self.logger.warning("Connection error, retrying in 3s")
-                await sleep(3)
-            else:
-                break
-        if response.status == 409:
-            match = re.match(
-                r"You can perform this action again in (\d+)", await response.text()
-            )
-            if match is None:
-                self.logger.warning(
-                    f"Unable to extract retry value from response: {await response.text()}"
-                )
-                raise RatelimitError(1)
-            raise RatelimitError(int(match.group(1)))
-        elif response.status != 200:
-            raise OperationFailedError(response.status, await response.text())
-        return response
-
-    async def bookmark(self, start: int, end: int, title: str):
-        result = await (
-            await self.request(
-                f"https://{self.server}/conversation/new",
-                {
-                    "roomId": self.roomID,
-                    "firstMessageId": start,
-                    "lastMessageId": end,
-                    "title": title,
-                },
-            )
-        ).text()
+                                event = _EventAdapter.validate_python(event_data)
+                                if isinstance(event, (MentionEvent, ReplyEvent)):
+                                    await self._request(
+                                        "/messages/ack", {"id": str(event.message_id)}
+                                    )
+                                yield event
+
+    @on_exception(runtime, RatelimitError, value=lambda e: e.retryAfter, jitter=None)
+    async def _request(self, url: str, data: dict[str, Any] = {}):
+        async with self.session.post(url, data=data | {"fkey": self.fkey}) as response:
+            text = await response.text()
+            match response.status:
+                case 409:
+                    if (match := BACKOFF_RESPONSE.fullmatch(text)) is None:
+                        self.logger.warning(f"Got 409 with malformed response: {text}")
+                        raise RatelimitError(1)
+                    raise RatelimitError(int(match.group(1)))
+                case 200:
+                    return text
+                case _:
+                    raise OperationFailedError(response.status, text)
+
+    async def _json_request(self, url: str, data: dict[str, Any] = {}):
+        response = await self._request(url, data)
         try:
-            result = json.loads(result)
-        except json.JSONDecodeError:
-            raise OperationFailedError(result)
-        else:
-            if not result.get("ok", False):
-                raise OperationFailedError(result)
-            return True
-
-    async def removeBookmark(self, title: str):
-        self.logger.info(f"Removing bookmark {title}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/conversation/delete/{self.roomID}/{title}"
-                    )
-                ).text()
-            )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
-
-    async def send(self, message: str) -> int:
-        assert len(message) >= 1, "Message cannot be empty!"
-        self.logger.info(f'Sending message "{message}"')
-        result = await (
-            await self.request(
-                f"https://{self.server}/chats/{self.roomID}/messages/new",
-                {"text": message},
-            )
-        ).text()
-        try:
-            result = json.loads(result)
-        except json.JSONDecodeError:
-            raise OperationFailedError(result)
-        return result["id"]
-
-    async def reply(self, target: int, message: str) -> int:
-        return await self.send(f":{target} {message}")
-
-    async def edit(self, messageID: int, newMessage: str):
-        assert len(newMessage) >= 1, "Message cannot be empty!"
-        self.logger.info(f'Editing message {messageID} to "{newMessage}"')
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}",
-                        {"text": newMessage},
-                    )
-                ).text()
-            )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
-
-    async def delete(self, messageID: int):
-        self.logger.info(f"Deleting message {messageID}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}/delete"
-                    )
-                ).text()
+            return json.loads(response)
+        except json.JSONDecodeError as e:
+            raise OperationFailedError("Failed to decode response", response) from e
+
+    async def _ok_request(self, url: str, data: dict[str, Any] = {}):
+        if (response := await self._request(url, data)) != "ok":
+            raise OperationFailedError(response)
+
+    async def send(self, message: str, reply_to: Optional[int] = None):
+        if not len(message):
+            raise ValueError("Cannot send an empty message!")
+        if reply_to is not None:
+            message = f":{reply_to} " + message
+        return (
+            await self._json_request(
+                f"/chats/{self.room_id}/messages/new", {"text": message}
             )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
+        )["id"]
 
-    async def star(self, messageID: int):
-        self.logger.info(f"Starring message {messageID}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}/star"
-                    )
-                ).text()
-            )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
+    async def edit(self, message_id: int, new_body: str):
+        await self._ok_request(f"/messages/{message_id}", {"text": new_body})
 
-    async def pin(self, messageID: int):
-        self.logger.info(f"Pinning message {messageID}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}/owner-star"
-                    )
-                ).text()
-            )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
+    async def _message_unary_op(self, op: str, message_id: int):
+        await self._ok_request(f"/messages/{message_id}/{op}")
 
-    async def unpin(self, messageID: int):
-        self.logger.info(f"Unpinning message {messageID}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}/unowner-star"
-                    )
-                ).text()
-            )
-            != "ok"
-        ):
-            raise OperationFailedError(result)
+    star = partialmethod(_message_unary_op, "star")
+    pin = partialmethod(_message_unary_op, "owner-star")
+    unpin = partialmethod(_message_unary_op, "unowner-star")
+    clear_stars = partialmethod(_message_unary_op, "unstar")
 
-    async def clearStars(self, messageID: int):
-        self.logger.info(f"Clearing stars on message {messageID}")
-        if not (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/messages/{messageID}/unstar"
-                    )
-                ).text()
+    async def move_messages(self, message_ids: set[int], target_room: int):
+        if (
+            result := await self._request(
+                f"/admin/movePosts/{self.room_id}",
+                {"to": target_room, "ids": ",".join(map(str, message_ids))},
             )
-            != "ok"
+        ) != str(len(message_ids)):
+            raise OperationFailedError("Failed to move some messages", result)
+
+    async def bookmark(self, start_message: int, end_message: int, bookmark_title: str):
+        payload = {
+            "roomId": self.room_id,
+            "firstMessageId": start_message,
+            "lastMessageId": end_message,
+            "title": bookmark_title,
+        }
+        if not (result := await self._json_request("/conversation/new", payload)).get(
+            "ok", False
         ):
-            raise OperationFailedError(result)
+            raise OperationFailedError("Failed to create bookmark", result)
 
-    async def moveMessages(self, messageIDs: Collection[int], roomID: int):
-        messageIDs = set(messageIDs)
-        self.logger.info(f"Moving messages {messageIDs} to room {roomID}")
-        if (
-            result := (
-                await (
-                    await self.request(
-                        f"https://{self.server}/admin/movePosts/{self.roomID}",
-                        {"to": roomID, "ids": ",".join(map(str, messageIDs))},
-                    )
-                ).text()
-            )
-        ) != str(len(messageIDs)):
-            raise OperationFailedError(result)
+    async def delete_bookmark(self, title: str):
+        await self._ok_request(f"/conversation/delete/{self.room_id}/{title}")
diff --git a/sechat/server.py b/sechat/server.py
deleted file mode 100644
index 68b63a9..0000000
--- a/sechat/server.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from enum import StrEnum
-
-class Server(StrEnum):
-    STACK_EXCHANGE = 'chat.stackexchange.com',
-    STACK_OVERFLOW = 'chat.stackoverflow.com',
-    META_STACK_EXCHANGE = 'chat.meta.stackexchange.com'
diff --git a/sechat/servers.py b/sechat/servers.py
new file mode 100644
index 0000000..2fc8d5f
--- /dev/null
+++ b/sechat/servers.py
@@ -0,0 +1,7 @@
+from enum import StrEnum
+
+
+class Server(StrEnum):
+    STACK_EXCHANGE = "https://chat.stackexchange.com"
+    STACK_OVERFLOW = "https://chat.stackoverflow.com"
+    META_STACK_EXCHANGE = "https://chat.meta.stackexchange.com"
diff --git a/sechat/tests.py b/sechat/tests.py
deleted file mode 100644
index b02029e..0000000
--- a/sechat/tests.py
+++ /dev/null
@@ -1,157 +0,0 @@
-import asyncio
-import os
-import unittest
-
-from sechat import Bot, Room, EventType, MentionEvent, MessageEvent
-
-bot = Bot()
-bot2 = Bot()
-EMAIL1, PASSWORD1, EMAIL2, PASSWORD2 = os.environ["EMAIL1"], os.environ["PASSWORD1"], None, None
-
-def setUpModule():
-    global EMAIL2, PASSWORD2
-    try:
-        EMAIL2, PASSWORD2 = os.environ["EMAIL2"], os.environ["PASSWORD2"]
-    except KeyError:
-        EMAIL2 = None
-
-class T00LoginTestCase(unittest.IsolatedAsyncioTestCase):
-    @classmethod
-    async def asyncSetUp(cls):
-        await bot.authenticate(EMAIL1, PASSWORD1, "https://codegolf.stackexchange.com")
-    
-    def testProps(self):
-        self.assertIsNotNone(bot.fkey)
-        self.assertIsNotNone(bot.userID)
-    
-class T01RoomTestCase(unittest.IsolatedAsyncioTestCase):
-    @classmethod
-    def setUp(cls):
-        cls.room = bot.joinRoom(1)
-    @classmethod
-    async def asyncTearDown(cls):
-        await cls.room.send("Stage 1 complete. DO NOT resume sending messages.")
-        await asyncio.sleep(3)
-
-    async def test00ConnectionProcess(self):
-        self.assertIsNotNone(self.room.fkey)
-        await asyncio.sleep(1)
-        await self.room.send("Currently testing message functionality. Please DO NOT send any other messages in this room until I say the tests are completed. The test will begin in 3 seconds. Thank you.")
-        await asyncio.sleep(3)
-    async def test01Messaging(self):
-        await self.room.send("Message 1")
-        await asyncio.sleep(3)
-        await self.room.send("Cooldown check 1")
-        await self.room.send("Cooldown check 2")
-        await self.room.send("Cooldown check 3")
-        await asyncio.sleep(3)
-    async def test02Editing(self):
-        ident = await self.room.send("Before edit")
-        await asyncio.sleep(2)
-        await self.room.edit(ident, "After edit")
-        await asyncio.sleep(2)
-    async def test03Deleting(self):
-        ident = await self.room.send("Going to be deleted")
-        await asyncio.sleep(2)
-        await self.room.delete(ident)
-        await asyncio.sleep(2)
-    '''
-    def test04Transcript(self):
-        messages = [i["content"] if "content" in i else False for i in self.room.getRecentMessages()[-7:]]
-        print(messages)
-        self.assertEqual(messages[0], "Message 1")
-        self.assertEqual(messages[3], "Cooldown check 3")
-        self.assertEqual(messages[5], "After edit")
-        self.assertFalse(messages[6])
-    '''
-        
-
-@unittest.skipIf(EMAIL2 is None, "Don't have two bots to test this with")
-class T02MultiUserTestCase(unittest.IsolatedAsyncioTestCase):
-    @classmethod
-    async def asyncSetUp(cls):
-        await bot2.authenticate(EMAIL2, PASSWORD2, "https://codegolf.stackexchange.com") # type: ignore Pylance doesn't realise skipIf is a typeguard
-        cls.room = bot.joinRoom(1)
-        cls.room2 = bot2.joinRoom(1)
-        cls.gotMessage = False
-        cls.gotReply = False
-    @classmethod
-    async def asyncTearDown(cls):
-        bot2.closeAllRooms()
-        await cls.room.send("Stage 2 complete. DO NOT resume sending messages.")
-        await asyncio.sleep(2)
-
-    async def onMessage(self, room: Room, event: MessageEvent):
-        if event.content == "Test message":
-            self.gotMessage = True
-    async def onMention(self, room, event):
-        self.gotReply = True
-        print(event)
-    
-    async def test00Starring(self):
-        ident = await self.room.send("This message will be starred")
-        await asyncio.sleep(2)
-        await self.room2.star(ident)
-        await asyncio.sleep(2)
-        await self.room2.send("Unstarring message")
-        await self.room2.star(ident)
-        await asyncio.sleep(4)
-    async def test01MessageEvents(self):
-        self.room.register(self.onMessage, EventType.MESSAGE)
-        await self.room2.send("Test message")
-        await asyncio.sleep(2)
-        self.room.unregister(self.onMessage, EventType.MESSAGE)
-        self.assertTrue(self.gotMessage)
-    async def test02ReplyEvents(self):
-        self.room.register(self.onMention, EventType.MENTION)
-        ident = await self.room.send("Test message")
-        await self.room2.reply(ident, "Test reply")
-        await asyncio.sleep(2)
-        self.room.unregister(self.onMention, EventType.MENTION)
-        self.assertTrue(self.gotReply)
-        
-
-class T03ROTestCase(unittest.IsolatedAsyncioTestCase):
-    @classmethod
-    def setUpClass(cls):
-        cls.room = bot.joinRoom(1)
-        cls.ident = None
-    @classmethod
-    async def asyncTearDown(cls):
-        await asyncio.sleep(2)
-    async def test00PinMessages(self):
-        self.ident = await self.room.send("This message will be pinned")
-        await asyncio.sleep(2)
-        await self.room.pin(self.ident)
-        await asyncio.sleep(2)
-        await self.room.unpin(self.ident)
-        await asyncio.sleep(2)
-        await self.room.send("Clearing stars on message")
-        await self.room.clearStars(self.ident)
-    @unittest.skip("No privs yet")
-    async def test02MoveMessages(self):
-        ident = await self.room.send("This message will be moved to https://chat.stackexchange.com/rooms/120733/osp-testing")
-        await asyncio.sleep(2)
-        await self.room.moveMessages([ident], 120733)
-        
-
-class T04LeaveTestCase(unittest.IsolatedAsyncioTestCase):
-    @classmethod
-    async def asyncSetUp(cls):
-        cls.room = bot.joinRoom(1)
-        await cls.room.send("Testing complete. You may now resume sending messages.")
-    def setUp(self):
-        self.room = bot.joinRoom(1)
-        await asyncio.sleep(2)
-    def test01LeaveRoom(self):
-        bot.closeAllRooms(1, True)
-        with self.assertRaises(ValueError):
-            bot.closeAllRooms(1)
-    def test02LeaveAllRooms(self):
-        bot.closeAllRooms(True)
-
-    @unittest.skip("Don't want to trigger the captcha")
-    def test03Logout(self):
-        bot.logout()
-        self.assertIsNone(bot.fkey)
-        self.assertIsNone(bot.userID)
diff --git a/sechat/version.py b/sechat/version.py
deleted file mode 100644
index 5a6bc65..0000000
--- a/sechat/version.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "2.0.0"
\ No newline at end of file