diff --git a/.gitignore b/.gitignore index d9c858c..8ad2561 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ celerybeat-schedule celerybeat.pid *.sage.py .env +.venv env/ venv/ ENV/ diff --git a/Dockerfile b/Dockerfile index f58efb1..226e72a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,10 +29,9 @@ WORKDIR "$APP_PATH" COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./ -COPY --chown=$USERNAME src/ src/ - RUN uv sync +COPY --chown=$USERNAME src/ src/ COPY --chown=$USERNAME migrations/ migrations/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index be3f7b2..0000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/Makefile b/Makefile index df06a40..1443b85 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,6 @@ fmt: fmt--mypy: uvx pre-commit run --all-files --color always mypy -fmt--add-noqa: - uvx ruff check --add-noqa . - .PHONY: tests tests: @@ -19,7 +16,7 @@ tests: uv run coverage xml serve: - uv run python -m src.apps.httpapi.litestar.main + uv run python -m src.huesoporro.main build: docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET) diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml index 9c41582..782ccdb 100644 --- a/charts/huesoporro/Chart.yaml +++ b/charts/huesoporro/Chart.yaml @@ -1,6 +1,24 @@ apiVersion: v2 -appVersion: 0.3.0 -description: A Helm chart for Kubernetes name: huesoporro +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. type: application -version: 0.3.0 + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.2.9 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "0.2.9" diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml index 0543d63..7f92219 100644 --- a/charts/huesoporro/values.yaml +++ b/charts/huesoporro/values.yaml @@ -1,62 +1,138 @@ -affinity: {} -autoscaling: - enabled: false - maxReplicas: 100 - minReplicas: 1 - targetCPUUtilizationPercentage: 80 -fullnameOverride: '' +# Default values for helm. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ image: - pullPolicy: Always repository: git.roboces.dev/catalin/huesoporro - tag: 0.3.0 + # This sets the pull policy for images. + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "0.2.9" + +# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] -ingress: +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +#This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account annotations: {} - className: '' + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: LoadBalancer + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 8000 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" hosts: - - host: chart-example.local - paths: - - path: / - pathType: ImplementationSpecific + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ livenessProbe: httpGet: path: /healthz port: http -nameOverride: '' -nodeSelector: {} -persistence: - accessModes: - - ReadWriteMany - annotations: {} - enabled: false - size: 10Gi - storageClassName: default - volumeOwner: - enabled: true - gid: 1000 - uid: 1000 -podAnnotations: {} -podLabels: {} -podSecurityContext: {} readinessProbe: httpGet: path: /healthz port: http -replicaCount: 1 -resources: {} + +#This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +persistence: + enabled: false + accessModes: + - ReadWriteMany + size: 10Gi + storageClassName: "default" + volumeOwner: + enabled: true + uid: 1000 + gid: 1000 + annotations: {} + secret: existingSecretName: huesoporro-secrets -securityContext: {} -service: - port: 8000 - type: LoadBalancer -serviceAccount: - annotations: {} - automount: true - create: true - name: '' -tolerations: [] -volumeMounts: [] -volumes: [] diff --git a/devenv.nix b/devenv.nix index 06edec4..2342729 100644 --- a/devenv.nix +++ b/devenv.nix @@ -10,6 +10,7 @@ languages.python.version = "3.12.8"; enterShell = '' + uv sync ''; dotenv.enable = true; diff --git a/migrations/20250226120422_rename_settings_to_chatbot.py b/migrations/20250226120422_rename_settings_to_chatbot.py deleted file mode 100644 index 0a697be..0000000 --- a/migrations/20250226120422_rename_settings_to_chatbot.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -This module contains a Caribou migration. - -Migration Name: rename_settings_to_chatbot -Migration Version: 20250226120422 -""" - - -def upgrade(connection): - sql = """ - ALTER TABLE settings RENAME TO chatbot; - """ - connection.execute(sql) - connection.commit() - - -def downgrade(connection): - # add your downgrade step here - pass diff --git a/migrations/20250228112643_uuids.py b/migrations/20250228112643_uuids.py deleted file mode 100644 index a283219..0000000 --- a/migrations/20250228112643_uuids.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -This module contains a Caribou migration. - -Migration Name: uuids -Migration Version: 20250228112643 -""" - -import uuid - - -def upgrade(connection): - """Several major upgrades - - Update all table's id columns to be UUIDs. - - Update the user's table `user` column to be named `username` - - Update the chatbot's table `user_id` column to reference the `id` column in the user's table, - thus changing the foreign key constraint. - """ - - # First, create temporary tables with the new schema - connection.execute(""" - CREATE TABLE users_new ( - id TEXT not null PRIMARY KEY, - username varchar(255) not null UNIQUE, - last_updated_at TIMESTAMP default CURRENT_TIMESTAMP, - created_at TIMESTAMP default CURRENT_TIMESTAMP, - external_auth JSON - ); - """) - - # Fetch users to generate UUIDs in Python - users = connection.execute( - "SELECT user, last_updated_at, created_at, external_auth FROM users" - ).fetchall() - - # Copy data from old users table to new users table, using Python-generated UUIDs - for user in users: - new_uuid = uuid.uuid4().hex - # Safely handle JSON - - connection.execute( - """ - INSERT INTO users_new (id, username, last_updated_at, created_at, external_auth) - VALUES (?, ?, ?, ?, ?) - """, - (new_uuid, user[0], user[1], user[2], user[3]), - ) - - # Create temporary quotes table with new schema - connection.execute(""" - CREATE TABLE quotes_new ( - id TEXT not null PRIMARY KEY, - quote varchar(255) not null UNIQUE, - author varchar(255), - channel varchar(255), - created_at TIMESTAMP default CURRENT_TIMESTAMP, - last_updated_at TIMESTAMP default CURRENT_TIMESTAMP - ); - """) - - # Fetch quotes to generate UUIDs in Python - quotes = connection.execute( - "SELECT quote, author, channel, created_at, last_updated_at FROM quotes" - ).fetchall() - - # Copy data from old quotes table to new quotes table, using Python-generated UUIDs - for quote in quotes: - new_uuid = uuid.uuid4().hex - connection.execute( - """ - INSERT INTO quotes_new (id, quote, author, channel, created_at, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - (new_uuid, quote[0], quote[1], quote[2], quote[3], quote[4]), - ) - - # Create mapping table to store the relationship between old user IDs and new UUIDs - connection.execute(""" - CREATE TEMPORARY TABLE user_id_mapping ( - old_user VARCHAR(255) not null, - new_id TEXT not null - ); - """) - - # Populate the mapping table - connection.execute(""" - INSERT INTO user_id_mapping (old_user, new_id) - SELECT username, id FROM users_new; - """) - - # Create temporary chatbot table with new schema - connection.execute(""" - CREATE TABLE chatbot_new ( - id TEXT not null PRIMARY KEY, - user_id TEXT not null UNIQUE, - automatic_generation_timer INTEGER default 300 not null, - automatic_quote_timer INTEGER default 500 not null, - mods VARCHAR(255), - created_at TIMESTAMP default CURRENT_TIMESTAMP, - last_updated_at TIMESTAMP default CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users_new (id) - ); - """) - - # Fetch chatbot data to generate UUIDs in Python - chatbots = connection.execute( - "SELECT user_id, automatic_generation_timer, automatic_quote_timer, mods, created_at, last_updated_at FROM chatbot" - ).fetchall() - - # Copy data from old chatbot table to new chatbot table with updated foreign key - for chatbot in chatbots: - # Get the new UUID for the user - user_mapping = connection.execute( - "SELECT new_id FROM user_id_mapping WHERE old_user = ?", (chatbot[0],) - ).fetchone() - new_user_id = user_mapping[0] if user_mapping else None - - if new_user_id: - new_chatbot_uuid = uuid.uuid4().hex - connection.execute( - """ - INSERT INTO chatbot_new (id, user_id, automatic_generation_timer, automatic_quote_timer, mods, created_at, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - new_chatbot_uuid, - new_user_id, - chatbot[1], - chatbot[2], - chatbot[3], - chatbot[4], - chatbot[5], - ), - ) - - # Drop old tables - connection.execute("DROP TABLE chatbot;") - connection.execute("DROP TABLE quotes;") - connection.execute("DROP TABLE users;") - - # Rename new tables to original names - connection.execute("ALTER TABLE users_new RENAME TO users;") - connection.execute("ALTER TABLE quotes_new RENAME TO quotes;") - connection.execute("ALTER TABLE chatbot_new RENAME TO chatbot;") - - # Drop temporary mapping table - connection.execute("DROP TABLE user_id_mapping;") - - # Commit all changes - connection.commit() - - -def downgrade(connection): - # add your downgrade step here - pass diff --git a/pyproject.toml b/pyproject.toml index 9a2888f..53cc9f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "huesoporro" -version = "0.3.0" -description = "Misc Twitch bot" +version = "0.2.9" +description = "Misc Twitch bots" readme = "README.md" authors = [ { name = "185504a9", email = "catalin@roboces.dev" } @@ -25,13 +25,6 @@ dependencies = [ "discord-py>=2.4.0", ] -[project.scripts] -huesoporro = "apps.cli.typer.main:app" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [tool.uv] dev-dependencies = [ "mypy>=1.13.0", @@ -40,7 +33,6 @@ dev-dependencies = [ "ruff>=0.8.3", "pytest-coverage>=0.0", "polyfactory>=2.18.1", - "types-pyyaml>=6.0.12.20241230", ] [[tool.mypy.overrides]] @@ -54,8 +46,7 @@ module = [ "caribou.migrate", "twitchio", "twitchio.ext", - "gtts", - "yt_dlp" + "gtts" ] ignore_missing_imports = true @@ -69,9 +60,3 @@ extend-ignore = ["S101", "ISC002", "COM812", "ISC001", "EM101", "EM102"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" - -[dependency-groups] -cli = [ - "typer>=0.15.1", - "yt-dlp>=2025.1.26", -] diff --git a/src/apps/__init__.py b/src/__init__.py similarity index 100% rename from src/apps/__init__.py rename to src/__init__.py diff --git a/src/apps/cli/typer/main.py b/src/apps/cli/typer/main.py deleted file mode 100644 index e69f8b3..0000000 --- a/src/apps/cli/typer/main.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - -from loguru import logger -from typer import Typer - -from huesoporro.actions.import_from_vod import ImportFromVODAction -from huesoporro.actions.misc.update_version_action import UpdateVersionAction -from huesoporro.settings import Settings -from huesoporro.svc.clean_cc_svc import CleanCCSvc -from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc - -app = Typer() - - -@app.command() -def import_vod_cc(channel_name: str, youtube_url: str, db_path: Path | None = None): - logger.info(f"Importing VOD closed captions for {channel_name} from {youtube_url}") - s = Settings.get(db_filepath=db_path) - import_from_vod_action = ImportFromVODAction( - download_closed_captions_svc=DownloadClosedCaptionsSvc(), - clean_cc_svc=CleanCCSvc(), - s=s, - ) - for cc_filepath in import_from_vod_action.run(channel_name, youtube_url): - logger.info(f"Closed captions imported from {cc_filepath}") - - -@app.command() -def update_version(version: str, dry_run: bool = False): - UpdateVersionAction().run(version, dry_run) diff --git a/src/apps/httpapi/__init__.py b/src/apps/httpapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/apps/httpapi/litestar/__init__.py b/src/apps/httpapi/litestar/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/apps/httpapi/litestar/dependencies.py b/src/apps/httpapi/litestar/dependencies.py deleted file mode 100644 index 3f3d1ad..0000000 --- a/src/apps/httpapi/litestar/dependencies.py +++ /dev/null @@ -1,171 +0,0 @@ -from litestar import Request -from litestar.exceptions import HTTPException - -from huesoporro.actions.chatbot.create_or_update_chatbot import ( - CreateOrUpdateChatbotAction, -) -from huesoporro.actions.chatbot.get_chatbot_by_user_id import GetChatbotByUserIdAction -from huesoporro.actions.users.authenticate_user import AuthenticateUserAction -from huesoporro.actions.users.get_user_by_jwt import GetUserByJWTAction -from huesoporro.infra.authenticator import TwitchAuthenticator -from huesoporro.infra.repos import ChatbotRepo, UserRepo -from huesoporro.libs.db import MarkovDatabase -from huesoporro.models import Chatbot, User -from huesoporro.settings import Settings -from huesoporro.svc.chatbot_svcs import ( - CreateChatbotSvc, - GetChatbotByUserIdSvc, - UpdateChatbotSvc, -) -from huesoporro.svc.store import SentenceStorerSvc -from huesoporro.svc.users_svcs import ( - CreateUserSvc, - GetTwitchAuthByAuthCodeSvc, - GetUserByUsernameSvc, - IsValidTokenSvc, - RefreshTokenSvc, - UpdateUserSvc, -) - - -def get_settings() -> Settings: - return Settings.get() - - -def get_authenticator(s: Settings) -> TwitchAuthenticator: - return TwitchAuthenticator(s=s) - - -def get_chatbot_repo(s: Settings): - return ChatbotRepo(s=s) - - -def get_get_chatbot_by_user_id_svc(chatbot_repo: ChatbotRepo): - return GetChatbotByUserIdSvc(repo=chatbot_repo) - - -def get_get_tokens_by_auth_code_svc( - twitch_authenticator: TwitchAuthenticator, s: Settings -): - return GetTwitchAuthByAuthCodeSvc(s=s, authenticator=twitch_authenticator) - - -def get_create_chatbot_svc(chatbot_repo: ChatbotRepo): - return CreateChatbotSvc(repo=chatbot_repo) - - -async def get_user_repo(s: Settings): - return UserRepo(s=s) - - -def get_create_user_svc(user_repo: UserRepo): - return CreateUserSvc(user_repo=user_repo) - - -def get_update_user_svc(user_repo: UserRepo): - return UpdateUserSvc(user_repo=user_repo) - - -def get_refresh_token_svc(twitch_authenticator: TwitchAuthenticator): - return RefreshTokenSvc(twitch_authenticator=twitch_authenticator) - - -def get_is_valid_token_svc(twitch_authenticator: TwitchAuthenticator): - return IsValidTokenSvc(authenticator=twitch_authenticator) - - -async def get_get_user_by_username_svc(user_repo: UserRepo): - return GetUserByUsernameSvc(user_repo=user_repo) - - -async def get_get_user_by_jwt_action( - get_user_by_username_svc: GetUserByUsernameSvc, - update_user_svc: UpdateUserSvc, - is_valid_token_svc: IsValidTokenSvc, - refresh_token_svc: RefreshTokenSvc, - s: Settings, -): - return GetUserByJWTAction( - get_user_by_username_svc=get_user_by_username_svc, - update_user_svc=update_user_svc, - refresh_token_svc=refresh_token_svc, - is_valid_token_svc=is_valid_token_svc, - s=s, - ) - - -async def authenticate( - request: Request, get_user_by_jwt_action: GetUserByJWTAction -) -> User: - token = request.query_params.get("huesoporro_token") - if token: - user = await get_user_by_jwt_action.run(token) - if not user: - raise HTTPException(detail="User does not exist", status_code=404) - return user - - cookies = request.cookies.get("huesoporroAuth") - if cookies: - user = await get_user_by_jwt_action.run(cookies) - if not user: - raise HTTPException(detail="User does not exist", status_code=404) - return user - - raise HTTPException(status_code=401, detail="Unauthorized") - - -async def get_sentences_storer_svc(db: MarkovDatabase): - return SentenceStorerSvc(db=db) - - -def get_update_chatbot_svc(chatbot_repo: ChatbotRepo): - return UpdateChatbotSvc(repo=chatbot_repo) - - -def get_create_or_update_chatbot_action( - create_chatbot_svc: CreateChatbotSvc, - update_chatbot_svc: UpdateChatbotSvc, - get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc, -): - return CreateOrUpdateChatbotAction( - create_chatbot_svc=create_chatbot_svc, - update_chatbot_svc=update_chatbot_svc, - get_chatbot_by_user_id_svc=get_chatbot_by_user_id_svc, - ) - - -def get_get_chatbot_by_user_id_action( - get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc, -): - return GetChatbotByUserIdAction( - get_chatbot_by_user_id_svc=get_chatbot_by_user_id_svc - ) - - -async def get_authenticate_action( - s: Settings, - get_tokens_by_auth_code_svc: GetTwitchAuthByAuthCodeSvc, - get_user_by_username_svc: GetUserByUsernameSvc, - create_user_svc: CreateUserSvc, - update_user_svc: UpdateUserSvc, -): - return AuthenticateUserAction( - s=s, - get_tokens_by_auth_code_svc=get_tokens_by_auth_code_svc, - get_user_by_username_svc=get_user_by_username_svc, - create_user_svc=create_user_svc, - update_user_svc=update_user_svc, - ) - - -async def chatbot( - get_chatbot_by_user_id_action: GetChatbotByUserIdAction, - create_or_update_chatbot_action: CreateOrUpdateChatbotAction, - user: User, -) -> Chatbot: - cb = await get_chatbot_by_user_id_action.run(user_id=user.id) - if cb: - return cb - return await create_or_update_chatbot_action.run( - user_id=user.id, - ) diff --git a/src/apps/httpapi/litestar/routes/__init__.py b/src/apps/httpapi/litestar/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/apps/httpapi/litestar/routes/api.py b/src/apps/httpapi/litestar/routes/api.py deleted file mode 100644 index 5bedf86..0000000 --- a/src/apps/httpapi/litestar/routes/api.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Literal - -from litestar import MediaType, Response, get, put -from litestar.datastructures import UploadFile -from litestar.response import Template -from pydantic import BaseModel, ConfigDict - -from huesoporro.actions.chatbot.create_or_update_chatbot import ( - CreateOrUpdateChatbotAction, -) -from huesoporro.actions.chatbot.get_chatbot_by_user_id import GetChatbotByUserIdAction -from huesoporro.bot import BotsManager -from huesoporro.models import Chatbot, User - - -class ManageBotDTO(BaseModel): - command: Literal["start", "stop"] - channel_name: str | None = None - - -class ImportTextFileDTO(BaseModel): - file: UploadFile - channel_name: str - - model_config = ConfigDict(arbitrary_types_allowed=True) - - -class UpdateChatbotDTO(BaseModel): - automatic_generation_timer: int = 300 - automatic_quote_timer: int = 500 - mods: list[str] - - -@get( - "/tts", - media_type=MediaType.HTML, -) -async def get_tts_overlay(user: User) -> Template: - return Template(template_name="tts.html") - - -@get( - "/tts/permalink", - media_type=MediaType.HTML, -) -async def get_tts_permalink(access_token: str) -> Template: - """Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query - param and not as a cookie, i.e. OBS""" - - return Template( - template_name="tts.html", - ) - - -@get( - "/", - media_type=MediaType.HTML, -) -async def get_index( - user: User, get_chatbot_by_user_id_action: GetChatbotByUserIdAction -) -> Template: - chatbot_settings = await get_chatbot_by_user_id_action.run(user_id=user.id) - return Template( - template_name="index.html", - context=chatbot_settings.model_dump() if chatbot_settings else {}, - ) - - -@put("/api/v1/bot") -async def manage_bot( - user: User, - data: ManageBotDTO, - create_or_update_chatbot_action: CreateOrUpdateChatbotAction, - get_chatbot_by_user_id_action: GetChatbotByUserIdAction, - bm: BotsManager, -) -> Response: - chatbot = await get_chatbot_by_user_id_action.run( - user_id=user.id - ) or await create_or_update_chatbot_action.run( - user_id=user.id, - ) - - if data.command == "start": - if not data.channel_name: - return Response({"message": "Channel name is required"}, status_code=400) - bm.add_bot(user, data.channel_name, chatbot=chatbot) # type: ignore[arg-type] - if user.username in bm.bots: - await bm.run_user_bot(user) - return Response({"message": "Bot started"}) - if data.command == "stop" and user.username in bm.bots: - await bm.stop_user_bot(user) - return Response({"message": "Bot stopped"}) - return Response({"message": "Invalid command"}, status_code=400) - - -@get("/api/v1/bot") -async def get_bot_status(user: User, bm: BotsManager) -> dict: - if user.username not in bm.bots: - return {"status": "ko"} - return {"status": "ok"} - - -@get("/api/v1/bot/settings") -async def get_bot_settings(chatbot: Chatbot) -> Chatbot: - return chatbot - - -@put("/api/v1/bot/settings") -async def save_bot_settings( - user: User, - data: UpdateChatbotDTO, - create_or_update_chatbot_action: CreateOrUpdateChatbotAction, -) -> dict: - await create_or_update_chatbot_action.run( - user_id=user.id, - automatic_generation_timer=data.automatic_generation_timer, - automatic_quote_timer=data.automatic_quote_timer, - mods=data.mods, - ) - return {"status": "ok"} diff --git a/src/huesoporro/actions/authenticate.py b/src/huesoporro/actions/authenticate.py new file mode 100644 index 0000000..9bf7b6a --- /dev/null +++ b/src/huesoporro/actions/authenticate.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class AuthenticateAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run( + self, + auth_code: str, + ): + tokens = await self.authenticator.get_token(auth_code) + username = tokens.userinfo["preferred_username"] + if username not in self.s.allowed_users: + raise ValueError(f"User {username} is not allowed to use this bot") + user = User(user=username, external_auth={"twitch": tokens.model_dump()}) + if await self.user_repo.get_by_user(user.user): + await self.user_repo.update(user) + else: + await self.user_repo.create(user) + return user.encode() diff --git a/src/huesoporro/actions/chatbot/__init__.py b/src/huesoporro/actions/chatbot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/huesoporro/actions/chatbot/create_or_update_chatbot.py b/src/huesoporro/actions/chatbot/create_or_update_chatbot.py deleted file mode 100644 index 73d0df8..0000000 --- a/src/huesoporro/actions/chatbot/create_or_update_chatbot.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from uuid import UUID - -from pydantic import BaseModel - -from huesoporro.models import Chatbot -from huesoporro.svc.chatbot_svcs import ( - CreateChatbotSvc, - GetChatbotByUserIdSvc, - UpdateChatbotSvc, -) - - -class CreateOrUpdateChatbotAction(BaseModel): - create_chatbot_svc: CreateChatbotSvc - update_chatbot_svc: UpdateChatbotSvc - get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc - - async def run( - self, - user_id: UUID, - automatic_generation_timer: int = 300, - automatic_quote_timer: int = 500, - mods: list[str] | None = None, - ) -> Chatbot: - mods = mods or [] - chatbot = await self.get_chatbot_by_user_id_svc.run(user_id=user_id) - if chatbot: - chatbot.automatic_generation_timer = automatic_generation_timer - chatbot.automatic_quote_timer = automatic_quote_timer - chatbot.mods = mods - return await self.update_chatbot_svc.run(chatbot=chatbot) - chatbot = Chatbot( - id=uuid.uuid4(), - user_id=user_id, - automatic_generation_timer=automatic_generation_timer, - automatic_quote_timer=automatic_quote_timer, - mods=mods, - ) - return await self.create_chatbot_svc.run(chatbot=chatbot) diff --git a/src/huesoporro/actions/chatbot/get_chatbot_by_user_id.py b/src/huesoporro/actions/chatbot/get_chatbot_by_user_id.py deleted file mode 100644 index 5d45a5e..0000000 --- a/src/huesoporro/actions/chatbot/get_chatbot_by_user_id.py +++ /dev/null @@ -1,16 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - -from huesoporro.models import Chatbot -from huesoporro.svc.chatbot_svcs import GetChatbotByUserIdSvc - - -class GetChatbotByUserIdAction(BaseModel): - get_chatbot_by_user_id_svc: GetChatbotByUserIdSvc - - async def run( - self, - user_id: UUID, - ) -> Chatbot | None: - return await self.get_chatbot_by_user_id_svc.run(user_id=user_id) diff --git a/src/huesoporro/actions/get_random_quote.py b/src/huesoporro/actions/get_random_quote.py new file mode 100644 index 0000000..47c7b5b --- /dev/null +++ b/src/huesoporro/actions/get_random_quote.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.models import Quote +from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc + + +class GetRandomQuoteAction(BaseModel): + quote_getter_svc: RandomQuoteGetterSvc + + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_getter_svc.run(channel_name=channel_name) diff --git a/src/huesoporro/actions/get_user_by_jwt.py b/src/huesoporro/actions/get_user_by_jwt.py new file mode 100644 index 0000000..4993b4c --- /dev/null +++ b/src/huesoporro/actions/get_user_by_jwt.py @@ -0,0 +1,39 @@ +from loguru import logger +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class GetUserByJWTAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run( + self, + jwt_token: str, + ) -> User: + user_data = User.decode(jwt_token) + username = user_data["user"] + user = await self.user_repo.get_by_user(username) + if not user: + raise ValueError(f"User {username} not found") + is_valid = await self.authenticator.token_is_valid( + user.external_auth["twitch"]["access_token"] + ) + + logger.info(f"Token {user} is valid: {is_valid}") + if is_valid: + return user + + logger.info(f"Refreshing token for user {user}") + twitch_auth = await self.authenticator.refresh_token( + user.external_auth["twitch"]["refresh_token"] + ) + user.external_auth["twitch"]["access_token"] = twitch_auth.access_token + user.external_auth["twitch"]["refresh_token"] = twitch_auth.refresh_token + await self.user_repo.update(user) + return user diff --git a/src/huesoporro/actions/import_from_vod.py b/src/huesoporro/actions/import_from_vod.py deleted file mode 100644 index be62ea5..0000000 --- a/src/huesoporro/actions/import_from_vod.py +++ /dev/null @@ -1,34 +0,0 @@ -from collections.abc import Generator -from pathlib import Path - -from pydantic import BaseModel, Field - -from huesoporro.libs.db import MarkovDatabase -from huesoporro.settings import Settings -from huesoporro.svc.clean_cc_svc import CleanCCSvc -from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc -from huesoporro.svc.store import SentenceStorerSvc - - -class ImportFromVODAction(BaseModel): - download_closed_captions_svc: DownloadClosedCaptionsSvc - - clean_cc_svc: CleanCCSvc - s: Settings = Field(default_factory=Settings.get) - - ignore_lines: set[str] = { - "WEBVTT", - "Kind: captions", - "Language: en", - "Language: es", - } - - def run(self, channel_name: str, youtube_url: str) -> Generator[Path, None, None]: - for cc_filepath in self.download_closed_captions_svc.run(youtube_url): - storer_svc = SentenceStorerSvc( - db=MarkovDatabase(channel=channel_name, settings=self.s) - ) - for line in self.clean_cc_svc.run(cc_filepath): - if line and line not in self.ignore_lines: - storer_svc.store_sentence(line.strip()) - yield cc_filepath diff --git a/src/huesoporro/actions/misc/__init__.py b/src/huesoporro/actions/misc/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/huesoporro/actions/misc/update_version_action.py b/src/huesoporro/actions/misc/update_version_action.py deleted file mode 100644 index cd2206c..0000000 --- a/src/huesoporro/actions/misc/update_version_action.py +++ /dev/null @@ -1,198 +0,0 @@ -import re -from collections.abc import Callable -from difflib import unified_diff -from pathlib import Path - -import yaml -from loguru import logger -from pydantic import BaseModel, ConfigDict -from rich import print # noqa: A004 -from rich.console import Console -from rich.panel import Panel -from rich.syntax import Syntax - - -class UpdateVersionAction(BaseModel): - project_root: Path = Path(__file__).parents[4] - files_to_update: dict[str, Callable] - console: Console = Console() - - model_config = ConfigDict(arbitrary_types_allowed=True) - - def __init__(self, **data): - files_to_update = { - "pyproject.toml": self._update_pyproject_toml, - "charts/huesoporro/values.yaml": self._update_values_yaml, - "charts/huesoporro/Chart.yaml": self._update_chart_yaml, - } - super().__init__(**data, files_to_update=files_to_update) - - def _read_file(self, filepath: Path) -> str: - """ - Read the contents of a file. - - Args: - filepath (Path): Path to the file to read. - - Returns: - str: File contents - """ - with filepath.open("r") as f: - return f.read() - - def _write_file(self, filepath: Path, content: str): - """ - Write content to a file. - - Args: - filepath (Path): Path to the file to write. - content (str): Content to write to the file. - """ - with filepath.open("w") as f: - f.write(content) - - def _update_pyproject_toml(self, filepath: Path, new_version: str) -> str: - """ - Update version in pyproject.toml. - - Args: - filepath (Path): Path to pyproject.toml - new_version (str): New version to set - - Returns: - str: Updated file content - """ - content = self._read_file(filepath) - version_pattern = r'(version\s*=\s*)[\'"](.+?)[\'"]' - return re.sub(version_pattern, rf'\1"{new_version}"', content) - - def _update_values_yaml(self, filepath: Path, new_version: str) -> str: - """ - Update image tag in values.yaml. - - Args: - filepath (Path): Path to values.yaml - new_version (str): New version to set - - Returns: - str: Updated file content - """ - with filepath.open("r") as file: - values = yaml.safe_load(file) - - # Assumes image.tag exists in the values.yaml - values["image"]["tag"] = new_version - - return yaml.dump(values, default_flow_style=False) - - def _update_chart_yaml(self, filepath: Path, new_version: str) -> str: - """ - Update version and appVersion in Chart.yaml. - - Args: - filepath (Path): Path to Chart.yaml - new_version (str): New version to set - - Returns: - str: Updated file content - """ - with filepath.open("r") as file: - chart_data = yaml.safe_load(file) - - chart_data["version"] = new_version - chart_data["appVersion"] = new_version - - return yaml.dump(chart_data, default_flow_style=False) - - def _generate_diff(self, original: str, updated: str, filename: str) -> str: - """ - Generate a unified diff between original and updated content. - - Args: - original (str): Original file content - updated (str): Updated file content - filename (str): Name of the file - - Returns: - str: Unified diff representation - """ - # Split content into lines - original_lines = original.splitlines(keepends=True) - updated_lines = updated.splitlines(keepends=True) - - # Generate unified diff - diff_lines = list( - unified_diff( - original_lines, - updated_lines, - fromfile=f"a/{filename}", - tofile=f"b/{filename}", - lineterm="", - ) - ) - - return "\n".join(diff_lines) - - def _rich_display_diff(self, diff: str): - """ - Display diff using rich for colorful output. - - Args: - diff (str): Unified diff to display - """ - if not diff: - return - - # Use Syntax for syntax highlighting - syntax = Syntax(diff, "diff", theme="ansi_dark") - - # Create a panel with the diff - panel = Panel( - syntax, title="Version Update Diff", border_style="cyan", expand=False - ) - - # Display the panel - self.console.print(panel) - - def run(self, new_version: str, dry_run: bool = False): - """ - Update version across all specified files. - - Args: - new_version (str): New version to set - dry_run (bool): Dry run mode with diff display - """ - for relative_path, update_func in self.files_to_update.items(): - filepath = self.project_root / relative_path - - if not filepath.exists(): - logger.warning(f"Warning: {filepath} not found. Skipping.") - continue - - try: - # Read original content - original_content = self._read_file(filepath) - - # Generate updated content - updated_content = update_func(filepath, new_version) - - if dry_run: - # Generate and display diff - diff = self._generate_diff( - original_content, updated_content, str(relative_path) - ) - - # Display the diff - if diff: - print(f"\nDiff for {relative_path}:") - self._rich_display_diff(diff) - else: - # Write updated content - self._write_file(filepath, updated_content) - print(f"Updated {relative_path}") - - except Exception as exc: # noqa: BLE001 - logger.error(f"Error updating {relative_path}: {exc}") - - if dry_run: - print("\nDry run complete. No files were modified.") diff --git a/src/huesoporro/actions/quotes/__init__.py b/src/huesoporro/actions/quotes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/huesoporro/actions/quotes/get_random_quote.py b/src/huesoporro/actions/quotes/get_random_quote.py deleted file mode 100644 index 67f60c6..0000000 --- a/src/huesoporro/actions/quotes/get_random_quote.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import BaseModel - -from huesoporro.models import Quote -from huesoporro.svc.quotes_svcs import GetRandomQuoteSvc - - -class GetRandomQuoteAction(BaseModel): - get_random_quote_svc: GetRandomQuoteSvc - - async def run(self, channel_name: str) -> Quote | None: - return await self.get_random_quote_svc.run(channel_name=channel_name) diff --git a/src/huesoporro/actions/refresh.py b/src/huesoporro/actions/refresh.py new file mode 100644 index 0000000..f28cc7c --- /dev/null +++ b/src/huesoporro/actions/refresh.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings + + +class RefreshAction(BaseModel): + user_repo: UserRepo + authenticator: TwitchAuthenticator + s: Settings + + async def run(self, user: User) -> str | None: + is_valid = await self.authenticator.token_is_valid( + user.external_auth["twitch"]["access_token"] + ) + if is_valid: + return None + + twitch_auth = await self.authenticator.refresh_token( + user.external_auth["twitch"]["refresh_token"] + ) + user.external_auth["twitch"]["access_token"] = twitch_auth.access_token + user.external_auth["twitch"]["refresh_token"] = twitch_auth.refresh_token + await self.user_repo.update(user) + return user.encode() diff --git a/src/huesoporro/actions/quotes/create_quote_action.py b/src/huesoporro/actions/store_quote.py similarity index 55% rename from src/huesoporro/actions/quotes/create_quote_action.py rename to src/huesoporro/actions/store_quote.py index 4d28c3a..87b5a84 100644 --- a/src/huesoporro/actions/quotes/create_quote_action.py +++ b/src/huesoporro/actions/store_quote.py @@ -1,15 +1,14 @@ import datetime -import uuid from pydantic import BaseModel -from huesoporro.models import Quote, User -from huesoporro.svc.is_mod import IsModSvc -from huesoporro.svc.quotes_svcs import CreateQuoteSvc +from src.huesoporro.models import Quote, User +from src.huesoporro.svc.is_mod import IsModSvc +from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc -class CreateQuoteAction(BaseModel): - create_quote_svc: CreateQuoteSvc +class StoreQuoteAction(BaseModel): + quote_storer_svc: QuoteStorerSvc is_mod_svc: IsModSvc async def run( @@ -18,11 +17,10 @@ class CreateQuoteAction(BaseModel): if not await self.is_mod_svc.run(user=user, username=username, channel=channel): return None new_quote = Quote( - id=uuid.uuid4(), quote=quote, - author=author, - channel_name=channel, + author=User(user=author, external_auth={}), + channel=User(user=channel, external_auth={}), created_at=datetime.datetime.now(datetime.UTC), last_updated_at=datetime.datetime.now(datetime.UTC), ) - return await self.create_quote_svc.run(new_quote) + return await self.quote_storer_svc.run(new_quote) diff --git a/src/huesoporro/actions/users/__init__.py b/src/huesoporro/actions/users/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/huesoporro/actions/users/authenticate_user.py b/src/huesoporro/actions/users/authenticate_user.py deleted file mode 100644 index 0b19904..0000000 --- a/src/huesoporro/actions/users/authenticate_user.py +++ /dev/null @@ -1,38 +0,0 @@ -import uuid - -from pydantic import BaseModel - -from huesoporro.models import User -from huesoporro.settings import Settings -from huesoporro.svc.users_svcs import ( - CreateUserSvc, - GetTwitchAuthByAuthCodeSvc, - GetUserByUsernameSvc, - UpdateUserSvc, -) - - -class AuthenticateUserAction(BaseModel): - get_tokens_by_auth_code_svc: GetTwitchAuthByAuthCodeSvc - get_user_by_username_svc: GetUserByUsernameSvc - create_user_svc: CreateUserSvc - update_user_svc: UpdateUserSvc - s: Settings - - async def run( - self, - auth_code: str, - ) -> User: - auth = await self.get_tokens_by_auth_code_svc.run(auth_code=auth_code) - username = auth.userinfo["preferred_username"] - user = await self.get_user_by_username_svc.run(username=username) - if user: - user.external_auth = {"twitch": auth} - await self.update_user_svc.run(user) - return user - user = User( - id=uuid.uuid4(), - username=username, - external_auth={"twitch": auth}, - ) - return await self.create_user_svc.run(user=user) diff --git a/src/huesoporro/actions/users/get_user_by_jwt.py b/src/huesoporro/actions/users/get_user_by_jwt.py deleted file mode 100644 index c1a7a75..0000000 --- a/src/huesoporro/actions/users/get_user_by_jwt.py +++ /dev/null @@ -1,40 +0,0 @@ -from loguru import logger -from pydantic import BaseModel - -from huesoporro.models import User -from huesoporro.settings import Settings -from huesoporro.svc.users_svcs import ( - GetUserByUsernameSvc, - IsValidTokenSvc, - RefreshTokenSvc, - UpdateUserSvc, -) - - -class GetUserByJWTAction(BaseModel): - get_user_by_username_svc: GetUserByUsernameSvc - update_user_svc: UpdateUserSvc - refresh_token_svc: RefreshTokenSvc - is_valid_token_svc: IsValidTokenSvc - s: Settings - - async def run( - self, - jwt_token: str, - ) -> User | None: - user_data = User.decode(jwt_token, settings=self.s) - username = user_data["username"] - user = await self.get_user_by_username_svc.run(username=username) - if not user: - raise ValueError(f"User {username} not found") - if await self.is_valid_token_svc.run(user=user): - logger.info( - f"User {username} has a correct twitch authentication token, returning user" - ) - return user - - logger.info( - f"User {username} has an invalid twitch authentication token, refreshing it" - ) - user = await self.refresh_token_svc.run(user=user) - return await self.update_user_svc.run(user=user) diff --git a/src/huesoporro/actions/users/refresh_user_jwt.py b/src/huesoporro/actions/users/refresh_user_jwt.py deleted file mode 100644 index c4cc4da..0000000 --- a/src/huesoporro/actions/users/refresh_user_jwt.py +++ /dev/null @@ -1,26 +0,0 @@ -from pydantic import BaseModel - -from huesoporro.models import User -from huesoporro.settings import Settings -from huesoporro.svc.users_svcs import ( - GetUserByUsernameSvc, - IsValidTokenSvc, - RefreshTokenSvc, - UpdateUserSvc, -) - - -class RefreshUserJwtAction(BaseModel): - get_user_by_username_svc: GetUserByUsernameSvc - update_user_svc: UpdateUserSvc - refresh_token_svc: RefreshTokenSvc - is_valid_token_svc: IsValidTokenSvc - s: Settings - - async def run(self, user: User) -> User | None: - """Return None if the user has a valid token, otherwise refresh it and return the new token""" - if await self.is_valid_token_svc.run(user=user): - return None - - user = await self.refresh_token_svc.run(user=user) - return await self.update_user_svc.run(user=user) diff --git a/src/apps/cli/__init__.py b/src/huesoporro/api/__init__.py similarity index 100% rename from src/apps/cli/__init__.py rename to src/huesoporro/api/__init__.py diff --git a/src/huesoporro/api/dependencies.py b/src/huesoporro/api/dependencies.py new file mode 100644 index 0000000..d1d9812 --- /dev/null +++ b/src/huesoporro/api/dependencies.py @@ -0,0 +1,67 @@ +from litestar import Request +from litestar.exceptions import HTTPException + +from src.huesoporro.actions.authenticate import AuthenticateAction +from src.huesoporro.actions.get_user_by_jwt import GetUserByJWTAction +from src.huesoporro.infra.authenticator import TwitchAuthenticator +from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import UserRepo +from src.huesoporro.models import User +from src.huesoporro.settings import Settings +from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc +from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc + + +def get_settings() -> Settings: + return Settings.get() + + +def get_authenticator(s: Settings) -> TwitchAuthenticator: + return TwitchAuthenticator(s=s) + + +def get_db(s: Settings): + return Database(s=s) + + +async def get_get_user_by_jwt_action( + user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings +): + return GetUserByJWTAction(user_repo=user_repo, authenticator=authenticator, s=s) + + +async def authenticate( + request: Request, get_user_by_jwt_action: GetUserByJWTAction +) -> User: + token = request.query_params.get("huesoporro_token") + if token: + return await get_user_by_jwt_action.run(token) + + cookies = request.cookies.get("huesoporroAuth") + if cookies: + return await get_user_by_jwt_action.run(cookies) + + raise HTTPException(status_code=401, detail="Unauthorized") + + +async def get_chatbot_settings_svc(db: Database): + return ChatbotSettingsGetterSvc(db=db) + + +async def store_chatbot_settings_svc(db: Database): + return ChatbotSettingsStorerSvc(db=db) + + +async def get_sentences_svc(db: Database): + return SentencesGetterSvc(db=db) + + +async def get_user_repo(s: Settings): + return UserRepo(s=s) + + +async def get_authenticate_action( + user_repo: UserRepo, authenticator: TwitchAuthenticator, s: Settings +): + return AuthenticateAction(user_repo=user_repo, authenticator=authenticator, s=s) diff --git a/src/apps/httpapi/litestar/errors.py b/src/huesoporro/api/errors.py similarity index 100% rename from src/apps/httpapi/litestar/errors.py rename to src/huesoporro/api/errors.py diff --git a/src/apps/httpapi/litestar/main.py b/src/huesoporro/api/main.py similarity index 56% rename from src/apps/httpapi/litestar/main.py rename to src/huesoporro/api/main.py index 44ee9c8..8df704e 100644 --- a/src/apps/httpapi/litestar/main.py +++ b/src/huesoporro/api/main.py @@ -6,44 +6,37 @@ from litestar.exceptions import HTTPException from litestar.static_files import StaticFilesConfig from litestar.template import TemplateConfig -from apps.httpapi.litestar.dependencies import ( +from src.huesoporro.api.dependencies import ( authenticate, get_authenticate_action, get_authenticator, - get_chatbot_repo, - get_create_chatbot_svc, - get_create_or_update_chatbot_action, - get_create_user_svc, - get_get_chatbot_by_user_id_action, - get_get_chatbot_by_user_id_svc, - get_get_tokens_by_auth_code_svc, + get_chatbot_settings_svc, + get_db, get_get_user_by_jwt_action, - get_get_user_by_username_svc, - get_is_valid_token_svc, - get_refresh_token_svc, - get_sentences_storer_svc, + get_sentences_svc, get_settings, - get_update_chatbot_svc, - get_update_user_svc, get_user_repo, + store_chatbot_settings_svc, ) -from apps.httpapi.litestar.errors import ( +from src.huesoporro.api.errors import ( after_exception_handler, http_exception_handler, httpx_status_error_handler, ) -from apps.httpapi.litestar.routes.api import ( +from src.huesoporro.api.routes.api import ( get_bot_settings, get_bot_status, get_index, + get_sentences, get_tts_overlay, get_tts_permalink, manage_bot, save_bot_settings, + save_new_sentence, ) -from apps.httpapi.litestar.routes.auth import get_code, login -from huesoporro.bot import BotsManager -from huesoporro.settings import Settings +from src.huesoporro.api.routes.auth import get_code, login +from src.huesoporro.bot import BotsManager +from src.huesoporro.settings import Settings @get("/healthz") @@ -64,6 +57,8 @@ def create_app(): get_bot_status, save_bot_settings, get_bot_settings, + get_sentences, + save_new_sentence, ], static_files_config=( StaticFilesConfig( @@ -88,26 +83,15 @@ def create_app(): "s": Provide(get_settings, use_cache=True), "a": Provide(get_authenticator, use_cache=True), "user": Provide(authenticate), + "db": Provide(get_db, use_cache=True), "bm": Provide(BotsManager, use_cache=True), - "sss": Provide(get_sentences_storer_svc), - "twitch_authenticator": Provide(get_authenticator), + "gbs": Provide(get_chatbot_settings_svc), + "sbs": Provide(store_chatbot_settings_svc), + "sgs": Provide(get_sentences_svc), + "authenticator": Provide(get_authenticator), "authenticate_action": Provide(get_authenticate_action), "user_repo": Provide(get_user_repo), - "chatbot_repo": Provide(get_chatbot_repo), - "create_user_svc": Provide(get_create_user_svc), - "update_chatbot_svc": Provide(get_update_chatbot_svc), - "update_user_svc": Provide(get_update_user_svc), - "create_chatbot_svc": Provide(get_create_chatbot_svc), - "refresh_token_svc": Provide(get_refresh_token_svc), - "is_valid_token_svc": Provide(get_is_valid_token_svc), - "get_user_by_username_svc": Provide(get_get_user_by_username_svc), - "get_chatbot_by_user_id_svc": Provide(get_get_chatbot_by_user_id_svc), - "get_tokens_by_auth_code_svc": Provide(get_get_tokens_by_auth_code_svc), "get_user_by_jwt_action": Provide(get_get_user_by_jwt_action), - "get_chatbot_by_user_id_action": Provide(get_get_chatbot_by_user_id_action), - "create_or_update_chatbot_action": Provide( - get_create_or_update_chatbot_action - ), }, ) diff --git a/src/apps/cli/typer/__init__.py b/src/huesoporro/api/routes/__init__.py similarity index 100% rename from src/apps/cli/typer/__init__.py rename to src/huesoporro/api/routes/__init__.py diff --git a/src/huesoporro/api/routes/api.py b/src/huesoporro/api/routes/api.py new file mode 100644 index 0000000..607789f --- /dev/null +++ b/src/huesoporro/api/routes/api.py @@ -0,0 +1,113 @@ +from typing import Literal + +from litestar import MediaType, Response, get, post, put +from litestar.response import Template +from pydantic import BaseModel + +from src.huesoporro.bot import BotsManager +from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.svc.get_chatbot_settings import ChatbotSettingsGetterSvc +from src.huesoporro.svc.get_sentences_svc import SentencesGetterSvc +from src.huesoporro.svc.store_settings import ChatbotSettingsStorerSvc + + +class ManageBotDTO(BaseModel): + command: Literal["start", "stop"] + channel_name: str | None = None + + +@get( + "/tts", + media_type=MediaType.HTML, +) +async def get_tts_overlay(user: User) -> Template: + return Template(template_name="tts.html") + + +@get( + "/tts/permalink", + media_type=MediaType.HTML, +) +async def get_tts_permalink(access_token: str) -> Template: + """Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query + param and not as a cookie, i.e. OBS""" + + return Template( + template_name="tts.html", + ) + + +@get( + "/", + media_type=MediaType.HTML, +) +async def get_index(user: User, gbs: ChatbotSettingsGetterSvc) -> Template: + chatbot_settings = await gbs.run(user=user) + return Template( + template_name="index.html", + context=chatbot_settings.model_dump() if chatbot_settings else {}, + ) + + +@put("/api/v1/bot") +async def manage_bot( + user: User, + data: ManageBotDTO, + gbs: ChatbotSettingsGetterSvc, + sbs: ChatbotSettingsStorerSvc, + bm: BotsManager, +) -> Response: + chatbot_settings = await gbs.run(user=user) + if not chatbot_settings: + await sbs.run(user=user, bot_settings=ChatbotSettings()) + chatbot_settings = await gbs.run(user=user) + if data.command == "start": + if not data.channel_name: + return Response({"message": "Channel name is required"}, status_code=400) + bm.add_bot(user, data.channel_name, chatbot_settings=chatbot_settings) # type: ignore[arg-type] + if user.user in bm.bots: + await bm.run_user_bot(user) + return Response({"message": "Bot started"}) + if data.command == "stop" and user.user in bm.bots: + await bm.stop_user_bot(user) + return Response({"message": "Bot stopped"}) + return Response({"message": "Invalid command"}, status_code=400) + + +@get("/api/v1/bot") +async def get_bot_status(user: User, bm: BotsManager) -> dict: + if user.user not in bm.bots: + return {"status": "ko"} + return {"status": "ok"} + + +@get("/api/v1/bot/settings") +async def get_bot_settings( + user: User, gbs: ChatbotSettingsGetterSvc +) -> ChatbotSettings | dict: + cbs = await gbs.run(user=user) + if not cbs: + return {"status": "Not found"} + return cbs + + +@put("/api/v1/bot/settings") +async def save_bot_settings( + user: User, data: ChatbotSettings, sbs: ChatbotSettingsStorerSvc +) -> dict: + await sbs.run(user=user, bot_settings=data) + return {"status": "ok"} + + +@get("/sentences") +async def get_sentences(user: User, sgs: SentencesGetterSvc) -> Template: + sentences = await sgs.run(user=user) + return Template( + template_name="sentences.html", + context={"sentences": [sentence.model_dump() for sentence in sentences]}, + ) + + +@post("/api/v1/sentences") +async def save_new_sentence(user: User, data: dict) -> dict: + return {"id": 54, "sentence": data["sentence"]} diff --git a/src/apps/httpapi/litestar/routes/auth.py b/src/huesoporro/api/routes/auth.py similarity index 75% rename from src/apps/httpapi/litestar/routes/auth.py rename to src/huesoporro/api/routes/auth.py index 446e7e3..7d930b2 100644 --- a/src/apps/httpapi/litestar/routes/auth.py +++ b/src/huesoporro/api/routes/auth.py @@ -4,14 +4,13 @@ from litestar import MediaType, get from litestar.datastructures.cookie import Cookie from litestar.response import Redirect, Template -from huesoporro.actions.users.authenticate_user import AuthenticateUserAction -from huesoporro.settings import Settings +from src.huesoporro.actions.authenticate import AuthenticateAction +from src.huesoporro.settings import Settings @get(path="/o/code") -async def get_code(code: str, authenticate_action: AuthenticateUserAction) -> Redirect: - user = await authenticate_action.run(code) - token = user.encode() +async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect: + token = await authenticate_action.run(code) return Redirect( "/", cookies=[ diff --git a/src/huesoporro/bot.py b/src/huesoporro/bot.py index c33cb47..14c39db 100644 --- a/src/huesoporro/bot.py +++ b/src/huesoporro/bot.py @@ -2,59 +2,63 @@ import asyncio import random from collections.abc import Callable from enum import StrEnum -from typing import ClassVar from loguru import logger from twitchio import Channel from twitchio.ext import commands, routines -from huesoporro.actions.quotes.create_quote_action import CreateQuoteAction -from huesoporro.actions.quotes.get_random_quote import GetRandomQuoteAction -from huesoporro.infra.repos import ChatbotRepo, QuoteRepo -from huesoporro.libs.db import MarkovDatabase -from huesoporro.models import Chatbot, User -from huesoporro.settings import Settings -from huesoporro.svc.backoff_service import BackoffService -from huesoporro.svc.generate import SentenceGeneratorSvc -from huesoporro.svc.hello import get_hello_generator_svc -from huesoporro.svc.is_mod import IsModSvc -from huesoporro.svc.quotes_svcs import CreateQuoteSvc, GetRandomQuoteSvc -from huesoporro.svc.store import SentenceStorerSvc +from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction +from src.huesoporro.actions.store_quote import StoreQuoteAction +from src.huesoporro.api.dependencies import get_settings +from src.huesoporro.infra.db import Database +from src.huesoporro.infra.repos import QuoteRepo +from src.huesoporro.libs.db import Database as MarkovDB +from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.svc.backoff_service import BackoffService +from src.huesoporro.svc.generate import SentenceGeneratorSvc +from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc +from src.huesoporro.svc.hello import HelloGeneratorSvc +from src.huesoporro.svc.is_mod import IsModSvc +from src.huesoporro.svc.quote_storer_svc import QuoteStorerSvc +from src.huesoporro.svc.store import SentenceStorerSvc class Bot(commands.Bot): - def __init__dependencies(self, channel: str, settings: Settings): - self.quote_repo = QuoteRepo(s=settings) - self.chatbot_repo = ChatbotRepo(s=settings) - self.get_random_quote_action = GetRandomQuoteAction( - get_random_quote_svc=GetRandomQuoteSvc(repo=self.quote_repo) - ) - self.create_quote_action = CreateQuoteAction( - create_quote_svc=CreateQuoteSvc(repo=self.quote_repo), - is_mod_svc=IsModSvc(repo=self.chatbot_repo), - ) - self.generate_svc = SentenceGeneratorSvc(db=MarkovDatabase(channel=channel)) - self.hello_svc = get_hello_generator_svc() - - def __init__(self, user: User, chatbot: Chatbot, channel: str, settings: Settings): + def __init__(self, user: User, chatbot_settings: ChatbotSettings, channel: str): super().__init__( token=user.twitch_access_token, prefix="!", initial_channels=[channel] ) - self.__init__dependencies(channel=channel, settings=settings) self.channel = channel self.user = user - self.chatbot = chatbot + self.generate_svc = SentenceGeneratorSvc(db=MarkovDB(channel=channel)) + self.hello_svc = HelloGeneratorSvc() + db = Database() + self.quote_repo = QuoteRepo(s=get_settings()) + self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo) + self.get_random_quote_action = GetRandomQuoteAction( + quote_getter_svc=self.get_random_quote_svc + ) + self.store_quote_action = StoreQuoteAction( + quote_storer_svc=QuoteStorerSvc(quote_repo=self.quote_repo), + is_mod_svc=IsModSvc(db=db), + ) + self.cbs = chatbot_settings self.quote_routine = routines.routine( - seconds=chatbot.automatic_quote_timer, wait_first=True + seconds=chatbot_settings.automatic_quote_timer, wait_first=True )(self.send_quote) self.generation_routine = routines.routine( - seconds=chatbot.automatic_generation_timer, wait_first=True + seconds=chatbot_settings.automatic_generation_timer, wait_first=True )(self.send_generation) async def event_ready(self): logger.info(f"Logged in as {self.nick}") logger.info(f"User id is {self.user_id}") + @commands.command(aliases=["h"]) + async def hello(self, ctx: commands.Context, username: str | None = None): + username = username or ctx.author.name + await ctx.send(self.hello_svc.run(username)) + @commands.command(aliases=["g"]) async def generate(self, ctx: commands.Context, *, words: str | None = None): sentence = await self.generate_svc.run(words) @@ -69,7 +73,7 @@ class Bot(commands.Bot): async def add_quote(self, ctx: commands.Context, *, quote: str): # extract author from quote; the author is the last word quote, author = quote.rsplit(" ", 1) - new_quote = await self.create_quote_action.run( + new_quote = await self.store_quote_action.run( user=self.user, channel=self.channel, quote=quote, @@ -94,9 +98,8 @@ class Bot(commands.Bot): quote = await self.get_random_quote_action.run(channel_name=self.channel) if quote: channel = self.get_channel_conn() - if channel: - logger.info(f"Sending random quote {quote.quote}") - await channel.send(quote.quote) + logger.info(f"Sending random quote {quote.quote}") + await channel.send(quote.quote) async def send_generation(self): sentence = await self.generate_svc.run() @@ -107,10 +110,10 @@ class Bot(commands.Bot): await channel.send(sentence) def start_routines(self): - if self.chatbot.automatic_quote_timer > 0: + if self.cbs.automatic_quote_timer > 0: logger.info("Starting quote routine") self.quote_routine.start(stop_on_error=False) - if self.chatbot.automatic_generation_timer > 0: + if self.cbs.automatic_generation_timer > 0: logger.info("Starting generation routine") self.generation_routine.start(stop_on_error=False) @@ -120,23 +123,6 @@ class Bot(commands.Bot): self.generation_routine.cancel() -class HelloMessagesCog(commands.Cog): - hello_patterns: ClassVar[list[str]] = ["hola", "HOLA", "hiii", "ayo"] - - def __init__(self, bot): - self.bot = bot - self.hello_svc = get_hello_generator_svc() - - @commands.Cog.event() - async def event_message(self, message): - if not message.author: - return - if message.content in self.hello_patterns: - hello = self.hello_svc.run(message.author.name) - if hello: - await message.channel_name.send(hello) - - class MessageType(StrEnum): COMMAND = "COMMAND" HELLO = "HELLO" @@ -150,6 +136,7 @@ class MessageHandler: """Handles different types of messages with their corresponding responses""" def __init__(self, channel_send_func: Callable): + self.hello_patterns = ["hola", "HOLA", "hiii", "ayo"] self.laugh_patterns = [ "om", "KEK", @@ -167,6 +154,8 @@ class MessageHandler: """Determines the type of message based on its content""" if content.startswith("!"): return MessageType.COMMAND + if content in self.hello_patterns: + return MessageType.HELLO if content == "Yes": return MessageType.YES if content.startswith("WHAT"): @@ -175,6 +164,10 @@ class MessageHandler: return MessageType.LAUGH return MessageType.OTHER + async def handle_hello(self, author_name: str, hello_svc) -> str: + """Handles hello messages""" + return hello_svc.run(author_name) + async def handle_laugh(self) -> str: """Handles laugh messages""" return random.choice(self.laugh_patterns) # noqa: S311 @@ -183,19 +176,20 @@ class MessageHandler: class SaveMessagesCog(commands.Cog): def __init__(self, bot): self.bot = bot - self.store_svc = SentenceStorerSvc(db=MarkovDatabase(channel=bot.channel_name)) - self.generate_svc = SentenceGeneratorSvc( - db=MarkovDatabase(channel=bot.channel_name) - ) + self.store_svc = SentenceStorerSvc(db=MarkovDB(channel=bot.channel)) + self.hello_svc = HelloGeneratorSvc() self.backoff_svc = BackoffService() self.message_handler = MessageHandler(self._send_message) + # Register a separate send function for each message type self.send_functions = { + MessageType.HELLO: self._create_typed_send("hello"), MessageType.YES: self._create_typed_send("yes"), MessageType.WHAT: self._create_typed_send("what"), MessageType.LAUGH: self._create_typed_send("laugh"), } + # Register each send function with its own backoff for func in self.send_functions.values(): self.backoff_svc.add_callable(func, backoff_seconds=10) @@ -204,7 +198,7 @@ class SaveMessagesCog(commands.Cog): async def typed_send(content: str): if hasattr(self, "current_message"): - await self.current_message.channel_name.send(content) + await self.current_message.channel.send(content) # Set a unique name for the function to ensure it's treated as distinct typed_send.__name__ = f"send_{type_name}" @@ -213,7 +207,7 @@ class SaveMessagesCog(commands.Cog): async def _send_message(self, content: str): """Generic send message function (for non-backoff uses)""" if hasattr(self, "current_message"): - await self.current_message.channel_name.send(content) + await self.current_message.channel.send(content) @commands.Cog.event() async def event_message(self, message): @@ -235,6 +229,10 @@ class SaveMessagesCog(commands.Cog): match msg_type: case MessageType.COMMAND: return + case MessageType.HELLO: + response = await self.message_handler.handle_hello( + message.author.name, self.hello_svc + ) case MessageType.YES: response = "Indeed" case MessageType.WHAT: @@ -250,36 +248,31 @@ class SaveMessagesCog(commands.Cog): class BotsManager: - def __init__(self, s: Settings): + def __init__(self): self.bots: dict[str, Bot] = {} - self.s = s - def add_bot(self, user: User, channel: str, chatbot: Chatbot): - if user.username in self.bots: - logger.info(f"Bot for {user.username} already exists") + def add_bot(self, user: User, channel: str, chatbot_settings: ChatbotSettings): + if user.user in self.bots: + logger.info(f"Bot for {user.user} already exists") return - - logger.info(f"Adding bot for {user.username}") - bot = Bot(user=user, channel=channel, chatbot=chatbot, settings=self.s) + logger.info(f"Adding bot for {user.user}") + bot = Bot(user=user, channel=channel, chatbot_settings=chatbot_settings) bot.add_cog(SaveMessagesCog(bot)) - bot.add_cog(HelloMessagesCog(bot)) - self.bots[user.username] = bot + self.bots[user.user] = bot async def run_user_bot(self, user: User): - if user.username not in self.bots: + if user.user not in self.bots: return - logger.info(f"Starting bot for {user.username}") - bot = self.bots[user.username] + logger.info(f"Starting bot for {user.user}") + bot = self.bots[user.user] task = asyncio.create_task(bot.start()) - task.add_done_callback( - lambda x: logger.info(f"Bot for {user.username} stopped") - ) + task.add_done_callback(lambda x: logger.info(f"Bot for {user.user} stopped")) bot.start_routines() async def stop_user_bot(self, user: User): - if user.username not in self.bots: + if user.user not in self.bots: return - bot = self.bots.pop(user.username) + bot = self.bots.pop(user.user) await bot.close() bot.stop_routines() diff --git a/src/huesoporro/infra/authenticator.py b/src/huesoporro/infra/authenticator.py index 987ff60..0405c8a 100644 --- a/src/huesoporro/infra/authenticator.py +++ b/src/huesoporro/infra/authenticator.py @@ -2,8 +2,8 @@ import httpx from litestar.exceptions import HTTPException from pydantic import BaseModel, ConfigDict, Field -from huesoporro.models import TwitchAuth -from huesoporro.settings import Settings +from src.huesoporro.models import TwitchAuth +from src.huesoporro.settings import Settings class TwitchAuthenticator(BaseModel): @@ -17,11 +17,11 @@ class TwitchAuthenticator(BaseModel): response = await self.client.post( "/oauth2/token", data={ - "client_id": self.s.twitch_client_id, - "client_secret": self.s.twitch_client_secret.get_secret_value(), + "client_id": Settings.get().twitch_client_id, + "client_secret": Settings.get().twitch_client_secret.get_secret_value(), "grant_type": "authorization_code", "code": code, - "redirect_uri": f"{self.s.server_hostname}o/code", + "redirect_uri": f"{Settings.get().server_hostname}o/code", }, headers={"Accept": "application/json"}, ) diff --git a/src/huesoporro/infra/db.py b/src/huesoporro/infra/db.py new file mode 100644 index 0000000..898b768 --- /dev/null +++ b/src/huesoporro/infra/db.py @@ -0,0 +1,99 @@ +import datetime +from contextlib import asynccontextmanager + +import aiosqlite +from loguru import logger +from pydantic import BaseModel, Field + +from src.huesoporro.models import ChatbotSettings, Sentence, User +from src.huesoporro.settings import Settings + + +class Database(BaseModel): + s: Settings = Field(default_factory=Settings.get) + + @asynccontextmanager + async def get_client(self, auto_commit=True): + logger.info(f"Opening database connection: {self.s.db_filepath}") + async with aiosqlite.connect(self.s.db_filepath) as db: + yield db + if auto_commit: + await db.commit() + + @staticmethod + def get_now() -> float: + return datetime.datetime.now(datetime.UTC).timestamp() + + async def save_chatbot_settings( + self, user: User, chatbot_settings: ChatbotSettings, auto_commit: bool = True + ): + async with self.get_client(auto_commit=auto_commit) as db: + current_settings = await self.get_chatbot_settings(user) + if current_settings: + await db.execute( + """UPDATE settings SET + automatic_generation_timer = ?, + automatic_quote_timer = ?, + mods = ?, + last_updated_at = ? + WHERE user_id = ? + """, + ( + chatbot_settings.automatic_generation_timer, + chatbot_settings.automatic_quote_timer, + chatbot_settings.mods_as_string, + self.get_now(), + user.user, + ), + ) + return + + await db.execute( + """INSERT INTO settings ( + user_id, + automatic_generation_timer, + automatic_quote_timer, + mods, + created_at, + last_updated_at + ) VALUES(?,?,?,?,?,?) + """, + ( + user.user, + chatbot_settings.automatic_generation_timer, + chatbot_settings.automatic_quote_timer, + chatbot_settings.mods_as_string, + self.get_now(), + self.get_now(), + ), + ) + + async def get_chatbot_settings(self, user: User) -> ChatbotSettings | None: + async with self.get_client() as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM settings WHERE user_id = ?", (user.user,) + ) as cursor: + result = await cursor.fetchone() + if not result: + return None + return ChatbotSettings(**dict(result)) + + async def save_sentence(self, sentence: str, auto_commit=True): + async with self.get_client(auto_commit=auto_commit) as db: + await db.execute( + "INSERT INTO sentences (sentence) VALUES (?)", + (sentence,), + ) + await db.commit() + + async def get_sentences(self, user: User) -> list[Sentence]: + async with self.get_client() as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM sentences WHERE user_id = ?", (user.user,) + ) as cursor: + result = await cursor.fetchall() + if not result: + return [] + return [Sentence(user=user, **dict(value)) for value in result] diff --git a/src/huesoporro/infra/gtts.py b/src/huesoporro/infra/gtts.py new file mode 100644 index 0000000..10814ec --- /dev/null +++ b/src/huesoporro/infra/gtts.py @@ -0,0 +1,48 @@ +from collections import deque +from hashlib import sha512 +from pathlib import Path + +from gtts import gTTS +from loguru import logger +from pydantic import BaseModel + +from src.huesoporro.settings import Settings + + +class GTTS(BaseModel): + s: Settings + chunk_size: int = 128 + text_max_length: int = 100 + queue: deque = deque() + + async def generate(self, text: str, lang: str = "pt", tld="com.br") -> Path: + text = text[: self.text_max_length] + raw_filename = f"{text.lower()}_{lang}_{tld}" + logger.info(f"Generating TTS for {raw_filename}") + filepath = ( + self.s.tts_cache_path / f"{sha512(raw_filename.encode()).hexdigest()}.mp3" + ) + tts = gTTS(text=text, lang=lang, tld=tld) + logger.info(f"Saving TTS to {filepath}") + tts.save(str(filepath)) + self.queue.append(filepath) + return filepath + + async def consume(self): + """If there are items in the queue, return a generator + that reads the file's bytes by chunks of self.chunk_size""" + while self.queue: + filepath = self.queue.popleft() + if not filepath.exists(): + logger.warning(f"File {filepath} does not exist, skipping") + continue + + logger.info(f"Reading file {filepath}") + try: + with filepath.open("rb") as f: + while chunk := f.read(self.chunk_size): + yield chunk + logger.info(f"Finished reading {filepath}") + except Exception as e: # noqa: BLE001 + logger.error(f"Error reading file {filepath}: {e}") + continue diff --git a/src/huesoporro/infra/repos.py b/src/huesoporro/infra/repos.py index 1156303..3256ffa 100644 --- a/src/huesoporro/infra/repos.py +++ b/src/huesoporro/infra/repos.py @@ -2,14 +2,12 @@ import json from abc import ABC, abstractmethod from contextlib import asynccontextmanager from typing import Generic, TypeVar -from uuid import UUID import aiosqlite from pydantic import BaseModel, Field -from huesoporro import utils -from huesoporro.models import Chatbot, Quote, User -from huesoporro.settings import Settings +from src.huesoporro.models import Quote, User +from src.huesoporro.settings import Settings T = TypeVar("T", bound=BaseModel) @@ -27,116 +25,75 @@ class IRepo(BaseModel, ABC, Generic[T]): @abstractmethod async def create(self, obj: T, auto_commit=True) -> T: - pass # pragma: no cover + pass @abstractmethod async def update(self, obj: T, auto_commit=True) -> T: - pass # pragma: no cover + pass @abstractmethod async def delete(self, obj: T, auto_commit=True): - pass # pragma: no cover + pass @abstractmethod - async def get_by_id(self, obj_id: UUID, auto_commit=True) -> T | None: - pass # pragma: no cover + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> T | None: + pass @abstractmethod async def list( self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True ) -> list[T]: - pass # pragma: no cover + pass class UserRepo(IRepo[User]): - @staticmethod - def _deserialize(data: dict) -> User: - return User( - id=UUID(data["id"]), - username=data["username"], - created_at=data["created_at"], - last_updated_at=data["last_updated_at"], - external_auth=json.loads(data["external_auth"]), - ) - - async def get_by_id(self, obj_id: UUID, auto_commit=True) -> User | None: - async with ( - self.get_client(auto_commit=auto_commit) as db, - await db.execute( - """ - SELECT * FROM users WHERE id = ? - """, - (obj_id.hex,), - ) as cursor, - ): - data = await cursor.fetchone() - if not data: - return None - return self._deserialize(data) + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> User | None: + raise NotImplementedError("Not implemented since it's not needed") async def create(self, obj: User, auto_commit=True) -> User: - if await self.get_by_username(obj.username): - raise ValueError(f"User {obj.username} already exists") - async with ( - self.get_client(auto_commit=auto_commit) as db, + async with self.get_client(auto_commit=auto_commit) as db: await db.execute( - """INSERT INTO users (id, username, external_auth, created_at, last_updated_at) - VALUES (?, ?, ?, ?, ?) - RETURNING * - """, - ( - obj.id.hex, - obj.username, - obj.serialize_external_auth(), - obj.created_at, - obj.last_updated_at, - ), - ) as cursor, - ): - data = await cursor.fetchone() - return self._deserialize(data) + "INSERT INTO users (user, external_auth) VALUES (?, ?)", + (obj.user, json.dumps(obj.external_auth)), + ) + return obj async def update(self, obj: User, auto_commit=True) -> User: - if not await self.get_by_id(obj.id): - raise ValueError(f"User {obj.username} does not exist") + if not await self.get_by_user(obj.user): + raise ValueError(f"User {obj.user} does not exist") async with ( self.get_client(auto_commit=auto_commit) as db, db.execute( """ UPDATE users - SET username = ?, - external_auth = ?, - last_updated_at = ? - WHERE id = ? + SET external_auth = ? + WHERE user = ? RETURNING * """, - ( - obj.username, - obj.serialize_external_auth(), - obj.last_updated_at, - obj.id.hex, - ), + (json.dumps(obj.external_auth), obj.user), ) as cursor, ): data = await cursor.fetchone() - return self._deserialize(data) + return User( + user=data["user"], external_auth=json.loads(data["external_auth"]) + ) async def delete(self, obj: User, auto_commit=True): async with self.get_client(auto_commit=auto_commit) as db: await db.execute( """ - DELETE FROM users WHERE id = ? + DELETE FROM users WHERE user = ? """, - (obj.id.hex,), + (obj.user,), ) - async def get_by_username(self, user: str, auto_commit=True) -> User | None: + async def get_by_user(self, user: str, auto_commit=True) -> User | None: async with ( self.get_client(auto_commit=auto_commit) as db, db.execute( """ - SELECT * FROM users WHERE username = ? + SELECT * FROM users WHERE user = ? """, (user,), ) as cursor, @@ -145,69 +102,49 @@ class UserRepo(IRepo[User]): if not data: return None return User( - id=UUID(data["id"]), - username=data["username"], - created_at=data["created_at"], - last_updated_at=data["last_updated_at"], - external_auth=json.loads(data["external_auth"]), + user=data["user"], external_auth=json.loads(data["external_auth"]) ) - async def list( # type: ignore[empty-body] + async def list( self, obj: User, offset: int = 0, limit: int = 10, auto_commit=True ) -> list[User]: - pass # pragma: no cover + raise NotImplementedError("Not implemented since it's not needed") async def count(self, obj: User, auto_commit=True): - pass # pragma: no cover + raise NotImplementedError("Not implemented since it's not needed") class QuoteRepo(IRepo[Quote]): - @staticmethod - def _deserialize(data: dict) -> Quote: - return Quote( - id=UUID(data["id"]), - quote=data["quote"], - author=data["author"], - channel_name=data["channel"], - created_at=data["created_at"], - last_updated_at=data["last_updated_at"], - ) - async def create(self, obj: Quote, auto_commit=True) -> Quote: - async with ( - self.get_client(auto_commit=auto_commit) as db, + async with self.get_client(auto_commit=auto_commit) as db: await db.execute( """ - INSERT INTO quotes (id, quote, author, channel, created_at, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?) - RETURNING * + INSERT INTO quotes (quote, author, channel, created_at, last_updated_at) + VALUES (?, ?, ?, ?, ?) """, ( - obj.id.hex, obj.quote, - obj.author, - obj.channel_name, + obj.author.user, + obj.channel.user, obj.created_at, obj.last_updated_at, ), - ) as cursor, - ): - data = await cursor.fetchone() - return self._deserialize(data) + ) + return obj - async def update(self, obj: Quote, auto_commit=True) -> Quote: # type: ignore[empty-body] - pass # pragma: no cover + async def update(self, obj: Quote, auto_commit=True) -> Quote: + raise NotImplementedError("Not implemented since it's not needed") async def delete(self, obj: Quote, auto_commit=True): - pass # pragma: no cover + raise NotImplementedError("Not implemented since it's not needed") - async def get_by_id(self, obj_id: UUID, auto_commit=True) -> Quote | None: # type: ignore[empty-body] - pass # pragma: no cover + async def get_by_id(self, obj_id: int | str, auto_commit=True) -> Quote | None: + raise NotImplementedError("Not implemented since it's not needed") - async def list( # type: ignore[empty-body] + async def list( self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True ) -> list[T]: - pass # pragma: no cover + raise NotImplementedError("Not implemented since it's not needed") async def get_random(self, channel_name: str, auto_commit=True) -> Quote | None: async with ( @@ -225,97 +162,10 @@ class QuoteRepo(IRepo[Quote]): data = await cursor.fetchone() if not data: return None - return self._deserialize(data) - - -class ChatbotRepo(IRepo[Chatbot]): - @staticmethod - def _deserialize(data: dict) -> Chatbot: - return Chatbot( - id=UUID(data["id"]), - user_id=data["user_id"], - automatic_generation_timer=data["automatic_generation_timer"], - automatic_quote_timer=data["automatic_quote_timer"], - mods=data["mods"].split(","), - last_updated_at=data["last_updated_at"], - created_at=data["created_at"], - ) - - async def create(self, obj: Chatbot, auto_commit=True) -> Chatbot: - if await self.get_by_user_id(obj.user_id): - raise ValueError(f"Chatbot {obj.user_id} already exists") - async with ( - self.get_client(auto_commit=auto_commit) as db, - await db.execute( - """INSERT INTO chatbot ( - id, - user_id, - automatic_generation_timer, - automatic_quote_timer, - mods, - created_at, - last_updated_at - ) VALUES(?,?,?,?,?,?,?) - RETURNING * - """, - ( - obj.id.hex, - obj.user_id.hex, - obj.automatic_generation_timer, - obj.automatic_quote_timer, - obj.mods_as_string, - obj.created_at, - obj.last_updated_at, - ), - ) as cursor, - ): - data = await cursor.fetchone() - return self._deserialize(data) - - async def update(self, obj: Chatbot, auto_commit=True) -> Chatbot: - if not await self.get_by_user_id(obj.user_id): - raise ValueError(f"Chatbot {obj.user_id} does not exist") - async with ( - self.get_client(auto_commit=auto_commit) as db, - await db.execute( - """UPDATE chatbot SET - automatic_generation_timer = ?, - automatic_quote_timer = ?, - mods = ?, - last_updated_at = ? - WHERE user_id = ? - RETURNING * - """, - ( - obj.automatic_generation_timer, - obj.automatic_quote_timer, - obj.mods_as_string, - utils.get_utc_now(), - obj.user_id.hex, - ), - ) as cursor, - ): - data = await cursor.fetchone() - return self._deserialize(data) - - async def delete(self, obj: T, auto_commit=True): - pass # pragma: no cover - - async def get_by_id(self, obj_id: UUID, auto_commit=True) -> Chatbot | None: # type: ignore[empty-body] - pass # pragma: no cover - - async def get_by_user_id(self, user_id: UUID) -> Chatbot | None: - async with self.get_client() as db: - db.row_factory = aiosqlite.Row - async with db.execute( - "SELECT * FROM chatbot WHERE user_id = ?", (user_id.hex,) - ) as cursor: - result = await cursor.fetchone() - if not result: - return None - return self._deserialize(result) - - async def list( # type: ignore[empty-body] - self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True - ) -> list[T]: - pass # pragma: no cover + return Quote( + quote=data["quote"], + author=User(user=data["author"], external_auth={}), + channel=User(user=data["channel"], external_auth={}), + created_at=data["created_at"], + last_updated_at=data["last_updated_at"], + ) diff --git a/src/huesoporro/libs/db.py b/src/huesoporro/libs/db.py index 615c706..ef523ca 100644 --- a/src/huesoporro/libs/db.py +++ b/src/huesoporro/libs/db.py @@ -4,12 +4,11 @@ import sqlite3 import string from typing import Any +import platformdirs from loguru import logger -from huesoporro.settings import Settings - -class MarkovDatabase: +class Database: """ The database created is called `MarkovChain_{channel}.db`, and populated with 27 + 27^2 = 756 tables. Firstly, 27 tables with the structure of @@ -87,11 +86,14 @@ class MarkovDatabase: to both get results from "hello" and "hello,". """ - def __init__(self, channel: str, settings: Settings | None = None): - settings = settings or Settings.get() - - self.db_path = settings.default_data_path / f"MarkovChain_{channel}.db" - self.user_data_path = self.db_path.parent + def __init__(self, channel: str): + self.user_data_path = platformdirs.user_data_path( + "huesoporro", + ensure_exists=True, + ) + self.db_path = ( + self.user_data_path / f"MarkovChain_{channel.replace('#', '').lower()}.db" + ) self._execute_queue: list = [] if self.db_path.is_file(): @@ -355,7 +357,7 @@ class MarkovDatabase: from nltk import ngrams - from huesoporro.libs.tokenizer import tokenize + from src.huesoporro.libs.tokenizer import tokenize channel = channel.replace("#", "").lower() copyfile( diff --git a/src/huesoporro/main.py b/src/huesoporro/main.py index fde026e..318c67d 100644 --- a/src/huesoporro/main.py +++ b/src/huesoporro/main.py @@ -1,6 +1,6 @@ import uvicorn -from huesoporro.settings import Settings +from src.huesoporro.settings import Settings if __name__ == "__main__": settings = Settings.get() diff --git a/src/huesoporro/models.py b/src/huesoporro/models.py index 7b9085a..86f977b 100644 --- a/src/huesoporro/models.py +++ b/src/huesoporro/models.py @@ -1,12 +1,10 @@ import datetime -import json from typing import Literal import jwt -from pydantic import UUID4, AwareDatetime, BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator -from huesoporro import utils -from huesoporro.settings import Settings +from src.huesoporro.settings import Settings class TwitchAuth(BaseModel): @@ -21,11 +19,8 @@ class ExternalAuth(BaseModel): class User(BaseModel): - id: UUID4 - username: str - external_auth: dict[Literal["twitch", "discord"], TwitchAuth] - created_at: AwareDatetime = Field(default_factory=utils.get_utc_now) - last_updated_at: AwareDatetime = Field(default_factory=utils.get_utc_now) + user: str + external_auth: dict[Literal["twitch", "discord"], dict] def encode( self, settings: Settings | None = None, exclude_fields: set[str] | None = None @@ -33,7 +28,7 @@ class User(BaseModel): s = settings or Settings.get() exclude_fields = exclude_fields or {"external_auth"} return jwt.encode( - self.model_dump(exclude=exclude_fields, mode="json"), + self.model_dump(exclude=exclude_fields), key=s.jwt_secret.get_secret_value(), algorithm="HS256", ) @@ -47,43 +42,12 @@ class User(BaseModel): @property def twitch_access_token(self): - return self.external_auth["twitch"].access_token - - @property - def twitch_refresh_token(self): - return self.external_auth["twitch"].refresh_token - - @twitch_access_token.setter # type: ignore[attr-defined,no-redef] - def twitch_access_token(self, value): - self.external_auth["twitch"].access_token = value - - @twitch_refresh_token.setter # type: ignore[attr-defined,no-redef] - def twitch_refresh_token(self, value): - self.external_auth["twitch"].refresh_token = value - - def serialize_external_auth(self) -> str: - """Return a JSON string with the inner pydantic model of external_auth serialized using model_dump""" - - return json.dumps({k: v.model_dump() for k, v in self.external_auth.items()}) + return self.external_auth["twitch"]["access_token"] -class Chatbot(BaseModel): - """A chatbot is an entity that holds settings for a given user, it is NOT tied to a channel. - - Attributes: - id (UUID4): The unique identifier for the chatbot. - user_id (UUID): The user_id of the user that owns the chatbot. - automatic_generation_timer (int): The timer for automatic generation of quotes. - automatic_quote_timer (int): The timer for automatic quotes. - mods (list[str]): The list of mods for the chatbot. - """ - - id: UUID4 - user_id: UUID4 +class ChatbotSettings(BaseModel): automatic_generation_timer: int = 300 automatic_quote_timer: int = 500 - created_at: AwareDatetime = Field(default_factory=utils.get_utc_now) - last_updated_at: AwareDatetime = Field(default_factory=utils.get_utc_now) mods: list[str] = Field(default_factory=list) @property @@ -100,16 +64,23 @@ class Chatbot(BaseModel): return v +class Sentence(BaseModel): + id: int + sentence: str + created_at: float + last_updated_at: float + user: User + + class Quote(BaseModel): - id: UUID4 quote: str - author: str - channel_name: str - created_at: datetime.datetime = Field(default_factory=utils.get_utc_now) - last_updated_at: datetime.datetime = Field(default_factory=utils.get_utc_now) + author: User + channel: User + created_at: datetime.datetime + last_updated_at: datetime.datetime def as_pretty(self) -> str: - return f"«{self.quote}» - {self.author}" + return f"«{self.quote}» - {self.author.user}" def as_pretty_saved(self): - return f"He añadido la cita «{self.quote}» de {self.author}" + return f"He añadido la cita «{self.quote}» de {self.author.user}" diff --git a/src/huesoporro/settings.py b/src/huesoporro/settings.py index a9dd968..358abc0 100644 --- a/src/huesoporro/settings.py +++ b/src/huesoporro/settings.py @@ -1,7 +1,6 @@ from functools import lru_cache from pathlib import Path -import platformdirs from pydantic import Field, HttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings @@ -9,19 +8,18 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): port: int = 8000 host: str = "0.0.0.0" # noqa: S104 - - default_data_path: Path = platformdirs.user_data_path( - "huesoporro", - ensure_exists=True, - ) static_files_path: Path = Field( default_factory=lambda: Path(__file__).parent / "static" ) templates_files_path: Path = Field( default_factory=lambda: Path(__file__).parent / "templates" ) - tts_cache_path: Path = default_data_path / "tts_files" - db_filepath: Path = default_data_path / "huesoporro.db" + tts_cache_path: Path = Field( + default_factory=lambda: Path(__file__).parent / "tts_files" + ) + db_filepath: Path = Field( + default_factory=lambda: Path(__file__).parent / "huesoporro.db" + ) twitch_client_id: str twitch_client_secret: SecretStr jwt_secret: SecretStr @@ -33,8 +31,8 @@ class Settings(BaseSettings): @staticmethod @lru_cache(maxsize=1) - def get(**data): - return Settings(**data) # type: ignore[call-arg] # pydantic-setting magic + def get(): + return Settings() # type: ignore[call-arg] # pydantic-setting magic @field_validator("allowed_users") @classmethod diff --git a/src/huesoporro/svc/chatbot_svcs.py b/src/huesoporro/svc/chatbot_svcs.py deleted file mode 100644 index 544fe51..0000000 --- a/src/huesoporro/svc/chatbot_svcs.py +++ /dev/null @@ -1,27 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - -from huesoporro.infra.repos import ChatbotRepo -from huesoporro.models import Chatbot - - -class CreateChatbotSvc(BaseModel): - repo: ChatbotRepo - - async def run(self, chatbot: Chatbot): - return await self.repo.create(chatbot) - - -class GetChatbotByUserIdSvc(BaseModel): - repo: ChatbotRepo - - async def run(self, user_id: UUID) -> Chatbot | None: - return await self.repo.get_by_user_id(user_id=user_id) - - -class UpdateChatbotSvc(BaseModel): - repo: ChatbotRepo - - async def run(self, chatbot: Chatbot): - return await self.repo.update(obj=chatbot) diff --git a/src/huesoporro/svc/clean_cc_svc.py b/src/huesoporro/svc/clean_cc_svc.py deleted file mode 100644 index 8680d71..0000000 --- a/src/huesoporro/svc/clean_cc_svc.py +++ /dev/null @@ -1,57 +0,0 @@ -import re -from collections.abc import Generator -from pathlib import Path - -from pydantic import BaseModel - - -class CleanCCSvc(BaseModel): - @classmethod - def clean_vtt_line(cls, line): - """ - Clean a single line of VTT text by removing timestamps and HTML tags. - - Args: - line (str): Single line from VTT file - - Returns: - str: Cleaned line or None if line should be skipped - """ - # Skip empty lines - if not line.strip(): - return None - # Skip WEBVTT header - if line.strip().startswith("WEBVTT"): - return None - - # Skip timestamp lines (e.g., "00:00:00.000 --> 00:00:02.000") - if re.match( - r"\d{2}:\d{2}:\d{2}\.\d{3}\s+-{2}>\s+\d{2}:\d{2}:\d{2}\.\d{3}", line - ): - return None - - # Skip numeric identifiers - if line.strip().isdigit(): - return None - - # Remove HTML-style tags - line = re.sub(r"<[^>]+>", "", line) - - # Remove multiple spaces - line = " ".join(line.split()) - - return line.strip() - - @classmethod - def process_vtt_file(cls, file_path: Path): - seen_lines = set() - - with file_path.open("r", encoding="utf-8") as f: - for line in f: - cleaned = cls.clean_vtt_line(line) - if line not in seen_lines: - seen_lines.add(line) - yield cleaned - - def run(self, cc_file_path: Path) -> Generator[str, None, None]: - return self.process_vtt_file(cc_file_path) diff --git a/src/huesoporro/svc/download_closed_captions.py b/src/huesoporro/svc/download_closed_captions.py deleted file mode 100644 index c8c2305..0000000 --- a/src/huesoporro/svc/download_closed_captions.py +++ /dev/null @@ -1,44 +0,0 @@ -import tempfile -from collections.abc import Generator -from pathlib import Path - -import yt_dlp -from loguru import logger -from pydantic import BaseModel - - -class DownloadClosedCaptionsSvc(BaseModel): - @staticmethod - def run(youtube_url: str, sub_lang: str = "es") -> Generator[Path, None, None]: - """Download closed captions from a yt video and save it to a temp file - - Args: - youtube_url: URL of the YouTube video - sub_lang: Language code for the subtitles (default: "es" for Spanish) - - Returns: - Path: Path to the downloaded subtitles file - - Raises: - ValueError: If subtitles are not available in the requested language - """ - temp_dir = Path(tempfile.mkdtemp()) - logger.info(f"Downloading subtitles for {youtube_url} to {temp_dir}") - ydl_opts = { - "skip_download": True, - "writesubtitles": True, - "writeautomaticsub": True, - "subtitleslangs": [sub_lang], - "subtitlesformat": "vtt", - "cookiesfrombrowser": ("firefox",), - "paths": {"home": str(temp_dir)}, - "quiet": True, - } - - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([youtube_url]) - return temp_dir.glob(f"*.{sub_lang}.vtt") - - except yt_dlp.utils.DownloadError as exc: - raise ValueError(f"Failed to download subtitles: {exc!s}") from exc diff --git a/src/huesoporro/svc/generate.py b/src/huesoporro/svc/generate.py index d4a1766..5bef7fe 100644 --- a/src/huesoporro/svc/generate.py +++ b/src/huesoporro/svc/generate.py @@ -3,8 +3,8 @@ import string from loguru import logger from pydantic import BaseModel, ConfigDict -from huesoporro.libs.db import MarkovDatabase as MarkovDB -from huesoporro.libs.tokenizer import detokenize, tokenize +from src.huesoporro.libs.db import Database as MarkovDB +from src.huesoporro.libs.tokenizer import detokenize, tokenize class SentenceGeneratorSvc(BaseModel): diff --git a/src/huesoporro/svc/get_chatbot_settings.py b/src/huesoporro/svc/get_chatbot_settings.py new file mode 100644 index 0000000..a1bfc2f --- /dev/null +++ b/src/huesoporro/svc/get_chatbot_settings.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.db import Database +from src.huesoporro.models import ChatbotSettings, User + + +class ChatbotSettingsGetterSvc(BaseModel): + db: Database + + async def run(self, user: User) -> ChatbotSettings | None: + return await self.db.get_chatbot_settings(user=user) diff --git a/src/huesoporro/svc/get_random_quote.py b/src/huesoporro/svc/get_random_quote.py new file mode 100644 index 0000000..055b633 --- /dev/null +++ b/src/huesoporro/svc/get_random_quote.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.repos import QuoteRepo +from src.huesoporro.models import Quote + + +class RandomQuoteGetterSvc(BaseModel): + quote_repo: QuoteRepo + + async def run(self, channel_name: str) -> Quote | None: + return await self.quote_repo.get_random(channel_name=channel_name) diff --git a/src/huesoporro/svc/get_sentences_svc.py b/src/huesoporro/svc/get_sentences_svc.py new file mode 100644 index 0000000..1cb0a1c --- /dev/null +++ b/src/huesoporro/svc/get_sentences_svc.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.db import Database +from src.huesoporro.models import Sentence, User + + +class SentencesGetterSvc(BaseModel): + db: Database + + async def run(self, user: User) -> list[Sentence]: + return await self.db.get_sentences(user=user) diff --git a/src/huesoporro/svc/hello.py b/src/huesoporro/svc/hello.py index 2edd970..5be2180 100644 --- a/src/huesoporro/svc/hello.py +++ b/src/huesoporro/svc/hello.py @@ -1,35 +1,20 @@ import random -from functools import lru_cache -from loguru import logger from pydantic import BaseModel, Field class HelloGeneratorSvc(BaseModel): - hellos: list[str] = [ - "Hola", - "Ayo", - "Hi", - "Bon día", - "Hola mi tremendo elemento", - "HOLA", - "hiii", - ] + hellos: list[str] = Field( + default_factory=lambda: [ + "Hola", + "Ayo", + "Hi", + "Bon día", + "Hola mi tremendo elemento", + "HOLA", + "hiii", + ] + ) - greeted_users: dict[str, str] = Field(default_factory=dict) - - def run(self, username: str) -> str | None: - if username in self.greeted_users: - logger.info(f"User {username} already greeted") - return None - - greeting = f"{random.choice(self.hellos)} @{username}" # noqa: S311 - - self.greeted_users[username] = greeting - - return greeting - - -@lru_cache(maxsize=1) -def get_hello_generator_svc() -> HelloGeneratorSvc: - return HelloGeneratorSvc() # pragma: no cover + def run(self, username: str): + return f"{random.choice(self.hellos)} @{username}" # noqa: S311 diff --git a/src/huesoporro/svc/is_mod.py b/src/huesoporro/svc/is_mod.py index 6e45f5e..d15e988 100644 --- a/src/huesoporro/svc/is_mod.py +++ b/src/huesoporro/svc/is_mod.py @@ -1,26 +1,20 @@ from pydantic import BaseModel -from huesoporro.infra.repos import ChatbotRepo -from huesoporro.models import User +from src.huesoporro.infra.db import Database +from src.huesoporro.models import User class IsModSvc(BaseModel): - repo: ChatbotRepo + db: Database - async def run(self, username: str, channel: str, user: User) -> bool: + async def run(self, user: User, username: str, channel: str) -> bool: """A user given username is a mod if they're the same as the current channel or if they're in the modlist - available in a user's settings - - Args: - username (str): The username to check if it's a mod - channel (str): The current channel - user (User): User object the chatbot belongs to - """ + available in a user's settings""" if channel == username: return True - chatbot = await self.repo.get_by_user_id(user_id=user.id) - if not chatbot: + chatbot_settings = await self.db.get_chatbot_settings(user=user) + if not chatbot_settings: return False - return username in chatbot.mods + return username in chatbot_settings.mods diff --git a/src/huesoporro/svc/quote_storer_svc.py b/src/huesoporro/svc/quote_storer_svc.py new file mode 100644 index 0000000..bc71320 --- /dev/null +++ b/src/huesoporro/svc/quote_storer_svc.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.repos import QuoteRepo +from src.huesoporro.models import Quote + + +class QuoteStorerSvc(BaseModel): + quote_repo: QuoteRepo + + async def run(self, quote: Quote) -> Quote: + return await self.quote_repo.create(quote) diff --git a/src/huesoporro/svc/quotes_svcs.py b/src/huesoporro/svc/quotes_svcs.py deleted file mode 100644 index 988afb8..0000000 --- a/src/huesoporro/svc/quotes_svcs.py +++ /dev/null @@ -1,18 +0,0 @@ -from pydantic import BaseModel - -from huesoporro.infra.repos import QuoteRepo -from huesoporro.models import Quote - - -class GetRandomQuoteSvc(BaseModel): - repo: QuoteRepo - - async def run(self, channel_name: str) -> Quote | None: - return await self.repo.get_random(channel_name=channel_name) - - -class CreateQuoteSvc(BaseModel): - repo: QuoteRepo - - async def run(self, quote: Quote) -> Quote: - return await self.repo.create(obj=quote) diff --git a/src/huesoporro/svc/store.py b/src/huesoporro/svc/store.py index 1a57b98..48e3115 100644 --- a/src/huesoporro/svc/store.py +++ b/src/huesoporro/svc/store.py @@ -1,14 +1,13 @@ -# pragma: no cover from loguru import logger from nltk.tokenize import sent_tokenize from pydantic import BaseModel, ConfigDict -from huesoporro.libs.db import MarkovDatabase -from huesoporro.libs.tokenizer import tokenize +from src.huesoporro.libs.db import Database as MarkovDB +from src.huesoporro.libs.tokenizer import tokenize class SentenceStorerSvc(BaseModel): - db: MarkovDatabase + db: MarkovDB key_length: int = 2 end_tag: str = "" diff --git a/src/huesoporro/svc/store_settings.py b/src/huesoporro/svc/store_settings.py new file mode 100644 index 0000000..96ef827 --- /dev/null +++ b/src/huesoporro/svc/store_settings.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + +from src.huesoporro.infra.db import Database +from src.huesoporro.models import ChatbotSettings, User + + +class ChatbotSettingsStorerSvc(BaseModel): + db: Database + + async def run(self, user: User, bot_settings: ChatbotSettings): + return await self.db.save_chatbot_settings( + user=user, chatbot_settings=bot_settings + ) diff --git a/src/huesoporro/svc/users_svcs.py b/src/huesoporro/svc/users_svcs.py deleted file mode 100644 index a6267b1..0000000 --- a/src/huesoporro/svc/users_svcs.py +++ /dev/null @@ -1,127 +0,0 @@ -from uuid import UUID - -from loguru import logger -from pydantic import BaseModel - -from huesoporro import utils -from huesoporro.infra.authenticator import TwitchAuthenticator -from huesoporro.infra.repos import UserRepo -from huesoporro.models import TwitchAuth, User -from huesoporro.settings import Settings - - -class CreateUserSvc(BaseModel): - user_repo: UserRepo - - async def run(self, user: User) -> User: - """Create a new user in the system - - Args: - user: User object to be created - - Returns: - The created User with any system-generated fields populated - """ - return await self.user_repo.create(user) - - -class UpdateUserSvc(BaseModel): - user_repo: UserRepo - - async def run(self, user: User) -> User: - """Update an existing user in the system - - Args: - user: User object with updated fields - - Returns: - The updated User - - Raises: - ValueError: If the user doesn't exist - """ - user.last_updated_at = utils.get_utc_now() - return await self.user_repo.update(user) - - -class DeleteUserSvc(BaseModel): - user_repo: UserRepo - - async def run(self, user: User) -> None: - """Delete a user from the system - - Args: - user: User object to be deleted - """ - await self.user_repo.delete(user) - - -class GetUserByIdSvc(BaseModel): - user_repo: UserRepo - - async def run(self, user_id: UUID) -> User | None: - """Retrieve a user by their ID - - Args: - user_id: UUID of the user to retrieve - - Returns: - User object if found, None otherwise - """ - return await self.user_repo.get_by_id(user_id) - - -class GetUserByUsernameSvc(BaseModel): - user_repo: UserRepo - - async def run(self, username: str) -> User | None: - """Retrieve a user by their username - - Args: - username: Username of the user to retrieve - - Returns: - User object if found, None otherwise - """ - return await self.user_repo.get_by_username(username) - - -class IsValidTokenSvc(BaseModel): - authenticator: TwitchAuthenticator - - async def run(self, user: User) -> bool: - return await self.authenticator.token_is_valid(user.twitch_access_token) - - -class RefreshTokenSvc(BaseModel): - twitch_authenticator: TwitchAuthenticator - - async def run(self, user: User) -> User: - """Refresh a user's Twitch token - - Args: - user: User object with the Twitch token to be refreshed - - Returns: - The updated User with the refreshed Twitch token - """ - - logger.info(f"Refreshing token for user {user}") - twitch_auth = await self.twitch_authenticator.refresh_token( - user.twitch_refresh_token - ) - user.twitch_access_token = twitch_auth.access_token # type: ignore[misc] - user.twitch_refresh_token = twitch_auth.refresh_token # type: ignore[misc] - return user - - -class GetTwitchAuthByAuthCodeSvc(BaseModel): - authenticator: TwitchAuthenticator - s: Settings - - async def run(self, auth_code: str) -> TwitchAuth: - auth = await self.authenticator.get_token(auth_code) - username = auth.userinfo["preferred_username"] - if username not in self.s.allowed_users: - raise ValueError(f"User {username} is not allowed to use this bot") - return auth diff --git a/src/huesoporro/templates/index.html b/src/huesoporro/templates/index.html index a2debe5..082c17e 100644 --- a/src/huesoporro/templates/index.html +++ b/src/huesoporro/templates/index.html @@ -185,6 +185,7 @@ const chatbotManager = new ChatbotManager(); chatbotManager.setEvents(); + }); diff --git a/src/huesoporro/templates/le_funny_dropdown.html b/src/huesoporro/templates/le_funny_dropdown.html index 6faa30d..a7f63a6 100644 --- a/src/huesoporro/templates/le_funny_dropdown.html +++ b/src/huesoporro/templates/le_funny_dropdown.html @@ -2,8 +2,9 @@ diff --git a/src/huesoporro/templates/sentences.html b/src/huesoporro/templates/sentences.html new file mode 100644 index 0000000..eb9f87d --- /dev/null +++ b/src/huesoporro/templates/sentences.html @@ -0,0 +1,137 @@ +{% include 'header.html' %} + + +
+ + +
+
+
+
+ + + +
+ + + + + + + + + + {% for sentence in sentences %} + + + + + + {% endfor %} + +
SentenceLast modifiedAction
{{ sentence.last_updated_at }} +
+ + +
+
+
+
+ + diff --git a/src/huesoporro/tts.py b/src/huesoporro/tts.py new file mode 100644 index 0000000..2e671f4 --- /dev/null +++ b/src/huesoporro/tts.py @@ -0,0 +1,131 @@ +import asyncio +from collections import deque +from hashlib import sha512 +from pathlib import Path + +from gtts import gTTS +from litestar import WebSocket +from loguru import logger + +from src.huesoporro.settings import Settings + + +class TTSManager: + TEXT_MAX_LENGTH: int = 400 + + def __init__(self, max_queue_size=10): + self.queue: deque = deque(maxlen=max_queue_size) + + # Connected WebSocket clients + self.clients: list[WebSocket] = [] + + # Currently playing audio + self.current_audio = None + + # Lock to prevent race conditions + self._lock = asyncio.Lock() + self._tasks = [] + self.s = Settings.get() + + def generate_tts(self, text, language="pt", tld="com.br"): + # Generate unique filename + text = text[0 : self.TEXT_MAX_LENGTH] + filename = ( + self.s.tts_cache_path / f"{sha512(text.lower().encode()).hexdigest()}.mp3" + ) + + if filename.exists(): + logger.info( + f"TTS already exists for '{text[:50]}' at {filename}. Returning it" + ) + return { + "filename": filename.name, + "text": text, + "filepath": str(filename), + "language": language, + "tld": tld, + } + logger.info(f"Generating TTS for '{text[:50]}'") + + # Generate TTS + tts = gTTS(text=text, lang=language, tld=tld) + tts.save(str(filename)) + + return { + "filename": filename.name, + "text": text, + "filepath": filename, + "language": language, + "tld": tld, + } + + async def add_to_queue(self, text, language="pt", tld="com.br"): + """Add TTS request to queue and start processing if not already running""" + async with self._lock: + # Generate TTS file + audio_info = self.generate_tts(text, language, tld) + + # Add to queue + self.queue.append(audio_info) + + # If this is the only item, start processing + if len(self.queue) == 1: + self._tasks.append(asyncio.create_task(self.process_queue())) + + return audio_info + + async def process_queue(self): + """Process queue and stream audio to connected clients""" + while True: + async with self._lock: + # Check if queue is empty + if not self.queue: + return + + # Get next audio file + audio_info = self.queue[0] + + try: + # Read the entire audio file + audio_path = Path(audio_info["filepath"]) + with audio_path.open("rb") as audio_file: + file_size = audio_path.stat().st_size + logger.info( + f"Streaming file: {audio_info['filename']}, Size: {file_size} bytes" + ) + + # Stream audio to all connected clients + for client in self.clients: + try: + # Reset file pointer to beginning + audio_file.seek(0) + + # Send file size first (as a header) + await client.send_text(f"FILE_HEADER:{file_size}") + + # Stream file in chunks + chunk = audio_file.read(128) # Larger chunk size + chunk_count = 0 + while chunk: + logger.info(f"Streamed {chunk_count} chunks") + chunk_count += 1 + await client.send_bytes(chunk) + chunk = audio_file.read(128) + + # Send file footer + await client.send_text("FILE_FOOTER") + + except Exception: # noqa: BLE001 + logger.error( + f"Error streaming to client {client.client}. Removing it." + ) + if client in self.clients: + self.clients.remove(client) + + except Exception as e: # noqa: BLE001 + logger.error(f"Error processing audio file: {e}") + + # Remove the processed item from the queue + async with self._lock: + if self.queue and self.queue[0] == audio_info: + self.queue.popleft() diff --git a/src/huesoporro/utils.py b/src/huesoporro/utils.py deleted file mode 100644 index 2a19c1e..0000000 --- a/src/huesoporro/utils.py +++ /dev/null @@ -1,5 +0,0 @@ -import datetime - - -def get_utc_now(): - return datetime.datetime.now(datetime.UTC) diff --git a/src/huesoporro/value_objects.py b/src/huesoporro/value_objects.py new file mode 100644 index 0000000..bf2cc92 --- /dev/null +++ b/src/huesoporro/value_objects.py @@ -0,0 +1,16 @@ +from enum import StrEnum + +from pydantic import BaseModel + + +class WebsocketCommands(StrEnum): + TTS_SEND = "tts_send" + CHATBOT_START = "chatbot_start" + CHATBOT_STOP = "chatbot_stop" + CHATBOT_STATUS = "chatbot_status" + CHATBOT_UPDATE = "chatbot_update" + + +class WebsocketMessage(BaseModel): + command: WebsocketCommands + data: dict diff --git a/tests/conftest.py b/tests/conftest.py index 40cdbdf..2163026 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,49 +1,27 @@ from pathlib import Path -from uuid import UUID import pytest from caribou.migrate import Database as CaribouDatabase from caribou.migrate import load_migrations from polyfactory.factories.pydantic_factory import ModelFactory from polyfactory.pytest_plugin import register_fixture -from pydantic import BaseModel -from huesoporro.infra.repos import ChatbotRepo, IRepo, QuoteRepo, UserRepo -from huesoporro.models import Chatbot, Quote, TwitchAuth, User -from huesoporro.settings import Settings -from huesoporro.svc.backoff_service import BackoffService -from huesoporro.svc.chatbot_svcs import ( - CreateChatbotSvc, - GetChatbotByUserIdSvc, - UpdateChatbotSvc, -) -from huesoporro.svc.hello import HelloGeneratorSvc -from huesoporro.svc.is_mod import IsModSvc -from huesoporro.svc.quotes_svcs import CreateQuoteSvc, GetRandomQuoteSvc -from huesoporro.svc.users_svcs import ( - CreateUserSvc, - DeleteUserSvc, - GetTwitchAuthByAuthCodeSvc, - GetUserByIdSvc, - GetUserByUsernameSvc, - UpdateUserSvc, -) +from src.huesoporro.infra.db import Database +from src.huesoporro.models import ChatbotSettings, Quote, User +from src.huesoporro.settings import Settings +from src.huesoporro.svc.backoff_service import BackoffService +from src.huesoporro.svc.is_mod import IsModSvc -@register_fixture() -class TwitchAuthFactory(ModelFactory[TwitchAuth]): ... - - -@register_fixture() -class ChatbotFactory(ModelFactory[Chatbot]): ... - - -@register_fixture() -class UserFactory(ModelFactory[User]): ... - - -@register_fixture() -class QuoteFactory(ModelFactory[Quote]): ... +@pytest.fixture +def user() -> User: + return User( + user="huesoporro", + external_auth={ + "twitch": {"token": "twitch_token"}, + "discord": {"token": "discord_token"}, + }, + ) @pytest.fixture @@ -54,169 +32,31 @@ def s(tmp_path: Path, user: User) -> Settings: twitch_client_id="test_client_id", twitch_client_secret="test_client_secret", # type: ignore[arg-type] # noqa: S106 jwt_secret="test_jwt_secret", # type: ignore[arg-type] # noqa: S106 - allowed_users=[user.username], + allowed_users=[user.user], ) @pytest.fixture -def db(s, cdb): - class BogusRepo(IRepo[BaseModel]): - async def get_by_id(self, obj_id: UUID, auto_commit=True) -> BaseModel | None: - pass - - async def list( # type: ignore[empty-body] - self, obj: BaseModel, offset: int = 0, limit: int = 10, auto_commit=True - ) -> list[BaseModel]: - pass - - async def create(self, obj: BaseModel, auto_commit: bool = True) -> BaseModel: # type: ignore[empty-body] - pass - - async def update(self, obj: BaseModel, auto_commit=True) -> BaseModel: # type: ignore[empty-body] - pass - - async def delete(self, obj: BaseModel, auto_commit=True) -> None: # type: ignore[empty-body] - pass - - async def get_by_user(self, user: str) -> BaseModel: # type: ignore[empty-body] - pass - - return BogusRepo(s=s).get_client - - -@pytest.fixture -def cdb(s) -> CaribouDatabase: +def db(s) -> Database: cdb = CaribouDatabase( db_url=s.db_filepath, ) cdb.initialize_version_control() migrations = load_migrations(Path(__file__).parents[1] / "migrations") cdb.upgrade(migrations) - return cdb + return Database(s=s) @pytest.fixture -async def user_repo(s, db): - return UserRepo(s=s) +def is_mod_svc(db) -> IsModSvc: + return IsModSvc(db=db) @pytest.fixture -def twitch_auth(twitch_auth_factory): - return twitch_auth_factory.build( - userinfo={ - "aud": twitch_auth_factory.__faker__.pystr(), - "exp": twitch_auth_factory.__faker__.pyint(), - "iat": twitch_auth_factory.__faker__.pyint(), - "iss": "https://id.twitch.tv/oauth2", - "sub": twitch_auth_factory.__faker__.pystr(), - "azp": twitch_auth_factory.__faker__.pystr(), - "preferred_username": twitch_auth_factory.__faker__.user_name(), - } - ) - - -@pytest.fixture -async def user(user_factory: UserFactory, twitch_auth) -> User: - return user_factory.build(external_auth={"twitch": twitch_auth}) - - -@pytest.fixture -async def persisted_user(user_repo: UserRepo, user: User) -> User: - return await user_repo.create(user) - - -@pytest.fixture -def create_user_svc(user_repo): - return CreateUserSvc(user_repo=user_repo) - - -@pytest.fixture -def update_user_svc(user_repo): - return UpdateUserSvc(user_repo=user_repo) - - -@pytest.fixture -def delete_user_svc(user_repo): - return DeleteUserSvc(user_repo=user_repo) - - -@pytest.fixture -def get_user_by_id_svc(user_repo): - return GetUserByIdSvc(user_repo=user_repo) - - -@pytest.fixture -def get_user_by_username_svc(user_repo): - return GetUserByUsernameSvc(user_repo=user_repo) - - -@pytest.fixture -def get_twitch_auth_by_auth_code_svc( - twitch_authenticator, fake_twitch_authenticator, user_repo, s -): - svc = GetTwitchAuthByAuthCodeSvc(authenticator=twitch_authenticator, s=s) - svc.authenticator = fake_twitch_authenticator - return svc - - -@pytest.fixture -async def chatbot_repo(s, db): - return ChatbotRepo(s=s) - - -@pytest.fixture -async def chatbot(chatbot_factory, user): - return chatbot_factory.build(user_id=user.id) - - -@pytest.fixture -async def persisted_chatbot(chatbot_repo, chatbot, persisted_user): - return await chatbot_repo.create(chatbot) - - -@pytest.fixture -async def create_chatbot_svc(chatbot_repo): - return CreateChatbotSvc(repo=chatbot_repo) - - -@pytest.fixture -async def get_chatbot_by_user_id_svc(chatbot_repo): - return GetChatbotByUserIdSvc(repo=chatbot_repo) - - -@pytest.fixture -async def update_chatbot_svc(chatbot_repo): - return UpdateChatbotSvc(repo=chatbot_repo) - - -@pytest.fixture -async def is_mod_svc(chatbot_repo): - return IsModSvc(repo=chatbot_repo) - - -@pytest.fixture -async def quote_repo(s, db): - return QuoteRepo(s=s) - - -@pytest.fixture -async def quote(quote_factory): - return quote_factory.build() - - -@pytest.fixture -async def persisted_quote(quote_repo, quote): - return await quote_repo.create(quote) - - -@pytest.fixture -async def create_quote_svc(quote_repo): - return CreateQuoteSvc(repo=quote_repo) - - -@pytest.fixture -async def get_random_quote_svc(quote_repo): - return GetRandomQuoteSvc(repo=quote_repo) +async def chatbot_settings(db: Database, user) -> ChatbotSettings: + cbs = ChatbotSettings(mods=[user.user, "allowed_user"]) + await db.save_chatbot_settings(user=user, chatbot_settings=cbs) + return cbs @pytest.fixture @@ -243,6 +83,10 @@ async def backoff_svc(backoff_callable, async_backoff_callable): return backoff_svc +@register_fixture() +class QuoteFactory(ModelFactory[Quote]): ... + + @pytest.fixture -async def hello_generator_svc(): - return HelloGeneratorSvc() +def quote(quote_factory): + return quote_factory.build() diff --git a/tests/test_actions.py b/tests/test_actions.py deleted file mode 100644 index 7980ee2..0000000 --- a/tests/test_actions.py +++ /dev/null @@ -1,243 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from huesoporro.actions.quotes.create_quote_action import CreateQuoteAction -from huesoporro.actions.quotes.get_random_quote import GetRandomQuoteAction -from huesoporro.actions.users.authenticate_user import AuthenticateUserAction -from huesoporro.actions.users.get_user_by_jwt import GetUserByJWTAction -from huesoporro.actions.users.refresh_user_jwt import RefreshUserJwtAction -from huesoporro.infra.authenticator import TwitchAuthenticator -from huesoporro.models import Quote, TwitchAuth -from huesoporro.svc.users_svcs import IsValidTokenSvc, RefreshTokenSvc - - -class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super().__call__(*args, **kwargs) - - -@pytest.fixture -def twitch_authenticator(s): - return TwitchAuthenticator(s=s) - - -@pytest.fixture -def fake_twitch_authenticator(): - return AsyncMock() - - -@pytest.fixture -def refresh_token_svc(twitch_authenticator, fake_twitch_authenticator, s): - svc = RefreshTokenSvc(twitch_authenticator=twitch_authenticator) - svc.twitch_authenticator = fake_twitch_authenticator - return svc - - -@pytest.fixture -def is_valid_svc(twitch_authenticator, fake_twitch_authenticator): - svc = IsValidTokenSvc(authenticator=twitch_authenticator) - svc.authenticator = fake_twitch_authenticator - return svc - - -@pytest.fixture -def get_user_by_jwt_action( - s, get_user_by_username_svc, update_user_svc, is_valid_svc, refresh_token_svc -): - return GetUserByJWTAction( - get_user_by_username_svc=get_user_by_username_svc, - update_user_svc=update_user_svc, - refresh_token_svc=refresh_token_svc, - is_valid_token_svc=is_valid_svc, - s=s, - ) - - -@pytest.fixture -def refresh_user_jwt_action( - s, get_user_by_username_svc, update_user_svc, is_valid_svc, refresh_token_svc -): - return RefreshUserJwtAction( - get_user_by_username_svc=get_user_by_username_svc, - update_user_svc=update_user_svc, - refresh_token_svc=refresh_token_svc, - is_valid_token_svc=is_valid_svc, - s=s, - ) - - -@pytest.fixture -def authenticate_user_action( # noqa: PLR0913 - s, - get_user_by_username_svc, - get_twitch_auth_by_auth_code_svc, - update_user_svc, - create_user_svc, - twitch_authenticator, -): - return AuthenticateUserAction( - get_user_by_username_svc=get_user_by_username_svc, - update_user_svc=update_user_svc, - create_user_svc=create_user_svc, - get_tokens_by_auth_code_svc=get_twitch_auth_by_auth_code_svc, - s=s, - ) - - -@pytest.fixture -def get_random_quote_action(get_random_quote_svc): - return GetRandomQuoteAction(get_random_quote_svc=get_random_quote_svc) - - -@pytest.fixture -def create_random_quote_action(create_quote_svc, is_mod_svc): - return CreateQuoteAction(create_quote_svc=create_quote_svc, is_mod_svc=is_mod_svc) - - -async def test_get_user_by_jwt_action_raises_value_error( - get_user_by_jwt_action: GetUserByJWTAction, user, s -): - with pytest.raises(ValueError, match=f"User {user.username} not found"): - await get_user_by_jwt_action.run(jwt_token=user.encode(settings=s)) - - -async def test_get_user_by_jwt_returns_user( - get_user_by_jwt_action: GetUserByJWTAction, - persisted_user, - s, - fake_twitch_authenticator, -): - fake_twitch_authenticator.token_is_valid.return_value = True - jwt = persisted_user.encode(settings=s) - assert await get_user_by_jwt_action.run(jwt_token=jwt) - - -async def test_get_user_by_jwt_returns_refreshed_user( - get_user_by_jwt_action: GetUserByJWTAction, - persisted_user, - s, - fake_twitch_authenticator, -): - jwt = persisted_user.encode(settings=s) - fake_twitch_authenticator.token_is_valid.return_value = False - fake_twitch_authenticator.refresh_token.return_value = TwitchAuth( - access_token="mocked", # noqa: S106 - refresh_token="mocked", # noqa: S106 - userinfo={}, - ) - assert await get_user_by_jwt_action.run(jwt_token=jwt) - - -async def test_refresh_user_jwt_returns_none_on_valid_token( - refresh_user_jwt_action: RefreshUserJwtAction, - persisted_user, - s, - fake_twitch_authenticator, -): - fake_twitch_authenticator.token_is_valid.return_value = True - assert await refresh_user_jwt_action.run(user=persisted_user) is None - - -async def test_refresh_user_jwt_returns_updated_user( - refresh_user_jwt_action: RefreshUserJwtAction, - persisted_user, - s, - fake_twitch_authenticator, -): - fake_twitch_authenticator.token_is_valid.return_value = False - fake_twitch_authenticator.refresh_token.return_value = TwitchAuth( - access_token="mocked", # noqa: S106 - refresh_token="mocked", # noqa: S106 - userinfo={}, - ) - user = await refresh_user_jwt_action.run(user=persisted_user) - assert user - assert user.twitch_access_token == "mocked" # noqa: S105 - assert user.twitch_refresh_token == "mocked" # noqa: S105 - - -async def test_authenticate_existing_user_returns_user( - persisted_user, authenticate_user_action, fake_twitch_authenticator -): - fake_twitch_authenticator.get_token.return_value = TwitchAuth( - access_token="mocked", # noqa: S106 - refresh_token="mocked", # noqa: S106 - userinfo={"preferred_username": persisted_user.username}, - ) - user = await authenticate_user_action.run(auth_code="mocked") - assert user.id == persisted_user.id - assert user.username == persisted_user.username - assert user.twitch_access_token == "mocked" != persisted_user.twitch_access_token # noqa: S105 - assert ( - user.twitch_refresh_token - == "mocked" # noqa: S105 - != persisted_user.external_auth["twitch"].refresh_token - ) - - -async def test_authenticate_raises_value_error_on_not_allowed_user( - authenticate_user_action, fake_twitch_authenticator -): - fake_twitch_authenticator.get_token.return_value = TwitchAuth( - access_token="mocked", # noqa: S106 - refresh_token="mocked", # noqa: S106 - userinfo={"preferred_username": "not_allowed"}, - ) - with pytest.raises(ValueError, match="User not_allowed is not allowed"): - await authenticate_user_action.run(auth_code="mocked") - - -async def test_authenticate_user( - authenticate_user_action, fake_twitch_authenticator, user -): - fake_twitch_authenticator.get_token.return_value = TwitchAuth( - access_token="mocked", # noqa: S106 - refresh_token="mocked", # noqa: S106 - userinfo={"preferred_username": user.username}, - ) - user = await authenticate_user_action.run(auth_code="mocked") - assert user.username == user.username - - -async def test_get_random_quote_action( - get_random_quote_action: GetRandomQuoteAction, persisted_quote: Quote -): - assert ( - await get_random_quote_action.run(persisted_quote.channel_name) - == persisted_quote - ) - - -async def test_create_quote_action_returns_none_on_not_mod_user( - create_random_quote_action: CreateQuoteAction, user -): - assert ( - await create_random_quote_action.run( - user=user, - channel=user.username, - quote="mocked", - author="mocked", - username="mocked", - ) - is None - ) - - -async def test_create_quote_action( - create_random_quote_action: CreateQuoteAction, user, quote: Quote, persisted_user -): - quote.channel_name = persisted_user.username - - new_quote = await create_random_quote_action.run( - user=persisted_user, - username=user.username, - channel=user.username, - quote=quote.quote, - author=quote.author, - ) - assert new_quote - quote.id = new_quote.id - quote.created_at = new_quote.created_at - quote.last_updated_at = new_quote.last_updated_at - assert quote == new_quote diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index ebeec79..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,21 +0,0 @@ -from tests.conftest import ChatbotFactory, QuoteFactory - - -def test_chatbot_format_mods_from_string_validator(chatbot_factory: ChatbotFactory): - chatbot = chatbot_factory.build(mods="foo,bar") - assert chatbot.mods == ["foo", "bar"] - - -def test_chatbot_mods_as_string_returns_empty_string(chatbot_factory: ChatbotFactory): - modsless_chatbot = chatbot_factory.build(mods=[]) - assert modsless_chatbot.mods_as_string == "" - - -def test_quote_as_pretty(quote_factory: QuoteFactory): - quote = quote_factory.build(quote="Foo", author="Bar") - assert quote.as_pretty() == "«Foo» - Bar" - - -def test_quote_as_pretty_saved(quote_factory: QuoteFactory): - quote = quote_factory.build(quote="Foo", author="Bar") - assert quote.as_pretty_saved() == "He añadido la cita «Foo» de Bar" diff --git a/tests/test_repos.py b/tests/test_repos.py index b7084de..5327108 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -1,95 +1,75 @@ -import uuid +import json import pytest -from huesoporro.infra.repos import QuoteRepo, UserRepo -from huesoporro.models import TwitchAuth, User +from src.huesoporro.infra.repos import QuoteRepo, UserRepo +from src.huesoporro.models import User -async def test_create_user_raises_value_error_for_existing_user( - user_repo: UserRepo, persisted_user: User -): - with pytest.raises( - ValueError, match=f"User {persisted_user.username} already exists" - ): - await user_repo.create(persisted_user) +@pytest.fixture +async def user_repo(s, db, user: User): + async with db.get_client() as client: + await client.execute( + "INSERT INTO users (user, external_auth) VALUES (?, ?)", + (user.user, json.dumps(user.external_auth)), + ) + + return UserRepo(s=s) -async def test_get_user_by_username(user_repo: UserRepo, persisted_user: User): - db_user = await user_repo.get_by_username(persisted_user.username) - assert db_user == persisted_user +@pytest.fixture +async def quote_repo(s, db): + async with db.get_client() as client: + await client.execute( + "INSERT INTO quotes (channel, quote, author) VALUES (?, ?, ?)", + ("channel", "quote", "author"), + ) + return QuoteRepo(s=s) + + +async def test_get_user(user_repo: UserRepo, user: User): + db_user = await user_repo.get_by_user(user.user) + assert db_user == user async def test_get_user_returns_none(user_repo: UserRepo): - assert await user_repo.get_by_username("unknown_user") is None + assert await user_repo.get_by_user("unknown_user") is None -async def test_get_user_by_id(user_repo: UserRepo, persisted_user: User): - db_user = await user_repo.get_by_id(persisted_user.id) - assert db_user == persisted_user +async def test_create_user(user_repo: UserRepo): + new_user = User( + user="new_user", external_auth={"twitch": {"token": "twitch_token"}} + ) + assert await user_repo.create(new_user) == new_user -async def test_get_user_by_id_returns_none(user_repo: UserRepo): - assert await user_repo.get_by_id(uuid.uuid4()) is None +async def test_update_users_tokens(user_repo: UserRepo, user: User): + new_tokens = {"twitch": {"token": "new_tokens"}} + user.external_auth = new_tokens # type: ignore[assignment] + assert await user_repo.update(user) == user -async def test_update_users_tokens( - user_repo: UserRepo, twitch_auth: TwitchAuth, persisted_user: User -): - twitch_auth.access_token = uuid.uuid4().hex - twitch_auth.refresh_token = uuid.uuid4().hex - new_tokens = {"twitch": twitch_auth} - persisted_user.external_auth = new_tokens # type: ignore[assignment] - assert await user_repo.update(persisted_user) == persisted_user - - -async def test_update_username(user_repo: UserRepo, persisted_user: User): - persisted_user.username = "new_username" - assert await user_repo.update(persisted_user) == persisted_user - - -async def test_update_non_existing_user_raises_value_error( - user_repo: UserRepo, user: User -): - with pytest.raises(ValueError, match=f"User {user.username} does not exist"): - await user_repo.update(user) - - -async def test_delete_user(user_repo: UserRepo, persisted_user: User): - assert await user_repo.delete(persisted_user) is None - assert await user_repo.get_by_id(persisted_user.id) is None - - -async def test_create_chatbot_raises_value_error_for_existing_chatbot( - chatbot_repo, chatbot_factory, persisted_chatbot -): - with pytest.raises( - ValueError, match=f"Chatbot {persisted_chatbot.user_id} already exists" - ): - await chatbot_repo.create( - chatbot_factory.build(user_id=persisted_chatbot.user_id) +async def test_update_non_existing_user_raises_value_error(user_repo: UserRepo): + with pytest.raises(ValueError, match="User unknown_user does not exist"): + await user_repo.update( + User( + user="unknown_user", external_auth={"twitch": {"token": "twitch_token"}} + ) ) -async def test_update_chatbot(persisted_chatbot, faker, chatbot_repo): - persisted_chatbot.automatic_generation_timer = faker.pyint() - persisted_chatbot.automatic_quote_timer = faker.pyint() - persisted_chatbot.mods = ["mod1", "mod2"] - - updated_chatbot = await chatbot_repo.update(persisted_chatbot) - persisted_chatbot.last_updated_at = updated_chatbot.last_updated_at - assert updated_chatbot == persisted_chatbot +async def test_delete_user(user_repo: UserRepo, user: User): + assert await user_repo.delete(user) is None + assert await user_repo.get_by_user(user.user) is None -async def test_update_chatbot_raises_value_error_on_non_existing_chatbot( - chatbot_repo, chatbot -): - with pytest.raises(ValueError, match=f"Chatbot {chatbot.user_id} does not exist"): - await chatbot_repo.update(chatbot) - - -async def test_get_random_quote(quote_repo: QuoteRepo, persisted_quote): - quote = await quote_repo.get_random(persisted_quote.channel_name) +async def test_get_random_quote(quote_repo: QuoteRepo): + quote = await quote_repo.get_random("channel") assert quote - assert quote.author == persisted_quote.author - assert quote.channel_name == persisted_quote.channel_name + assert quote.author.user == "author" + assert quote.channel.user == "channel" + + +async def test_create_quote(quote, quote_repo): + new_quote = await quote_repo.create(quote) + assert new_quote == quote diff --git a/tests/test_svc.py b/tests/test_svc.py index 4ca3a8e..c88e1c9 100644 --- a/tests/test_svc.py +++ b/tests/test_svc.py @@ -1,52 +1,10 @@ import asyncio import time -import uuid import pytest -from huesoporro.models import Chatbot, User -from huesoporro.svc.is_mod import IsModSvc - - -async def test_create_user_svc_returns_user(create_user_svc, user): - created_user = await create_user_svc.run(user=user) - assert created_user == user - - -async def test_update_user_svc_returns_user(update_user_svc, persisted_user): - persisted_user.username = "new_username" - updated_user = await update_user_svc.run(user=persisted_user) - assert updated_user == persisted_user - - -async def test_update_user_svc_raises_value_error_for_non_existing_user( - update_user_svc, user -): - with pytest.raises(ValueError, match=f"User {user.username} does not exist"): - await update_user_svc.run(user=user) - - -async def test_delete_user_svc_returns_none(delete_user_svc, persisted_user): - assert await delete_user_svc.run(user=persisted_user) is None - - -async def test_get_user_by_id_svc_returns_user(get_user_by_id_svc, persisted_user): - assert await get_user_by_id_svc.run(user_id=persisted_user.id) == persisted_user - - -async def test_get_user_by_id_returns_none(get_user_by_id_svc): - assert await get_user_by_id_svc.run(user_id=uuid.uuid4()) is None - - -async def test_get_user_by_username(get_user_by_username_svc, persisted_user): - assert ( - await get_user_by_username_svc.run(username=persisted_user.username) - == persisted_user - ) - - -async def test_get_user_by_username_returns_none(get_user_by_username_svc): - assert await get_user_by_username_svc.run(username="unknown_user") is None +from src.huesoporro.models import ChatbotSettings, User +from src.huesoporro.svc.is_mod import IsModSvc async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: User): @@ -56,30 +14,27 @@ async def test_is_mod_svc_returns_true_for_channel(is_mod_svc: IsModSvc, user: U async def test_is_mod_svc_returns_true_for_user_in_modlist( is_mod_svc: IsModSvc, - persisted_user: User, - persisted_chatbot: Chatbot, + user: User, + chatbot_settings: ChatbotSettings, ): - assert persisted_chatbot.mods - for mod in persisted_chatbot.mods: - is_mod = await is_mod_svc.run( - user=persisted_user, username=mod, channel=persisted_user.username - ) - assert is_mod + is_mod = await is_mod_svc.run( + user=user, username=chatbot_settings.mods[1], channel=user.user + ) + assert is_mod -async def test_is_mod_svc_returns_false_for_chatbotless_user( +async def test_is_mod_svc_returns_false_for_settingless_user( is_mod_svc: IsModSvc, user: User ): is_mod = await is_mod_svc.run(user=user, username="TestUser", channel="TestUser2") assert not is_mod +@pytest.mark.usefixtures("chatbot_settings") async def test_is_mod_svc_returns_false_for_user_not_in_modlist( - is_mod_svc: IsModSvc, persisted_user: User, persisted_chatbot + is_mod_svc: IsModSvc, user: User ): - is_mod = await is_mod_svc.run( - user=persisted_user, username="TestUser2", channel=persisted_user.username - ) + is_mod = await is_mod_svc.run(user=user, username="TestUser2", channel=user.user) assert not is_mod @@ -139,54 +94,3 @@ async def test_backoff_svc_raises_value_error_for_sync_called_from_async( ): with pytest.raises(ValueError, match="Cannot call async function with .call()"): backoff_svc.call(async_backoff_callable) - - -async def test_create_quote_svc(create_quote_svc, quote): - created_quote = await create_quote_svc.run(quote) - assert created_quote == quote - - -async def test_get_random_quote_svc_returns_random_quote( - get_random_quote_svc, persisted_quote -): - random_quote = await get_random_quote_svc.run( - channel_name=persisted_quote.channel_name - ) - assert random_quote == persisted_quote - - -def test_generate_hello_svc_returns_greeting(hello_generator_svc, user): - greeting = hello_generator_svc.run(username=user.username) - assert greeting.split()[0] in hello_generator_svc.hellos - - -def test_generate_hello_svc_returns_none_on_already_greeted_user( - hello_generator_svc, user -): - assert hello_generator_svc.run(username=user.username) - assert not hello_generator_svc.run(username=user.username) - - -async def test_create_chatbot_svc_returns_chatbot(create_chatbot_svc, chatbot): - created_chatbot = await create_chatbot_svc.run(chatbot) - assert created_chatbot == chatbot - - -async def test_get_chatbot_by_user_id_svc_returns_chatbot( - get_chatbot_by_user_id_svc, persisted_chatbot -): - existing_chatbot = await get_chatbot_by_user_id_svc.run( - user_id=persisted_chatbot.user_id - ) - assert existing_chatbot == persisted_chatbot - - -async def test_update_chatbot_svc_returns_chatbot( - update_chatbot_svc, persisted_chatbot, faker -): - persisted_chatbot.automatic_quote_timer = faker.pyint() - persisted_chatbot.automatic_quote_timer = faker.pyint() - persisted_chatbot.mods = [faker.word() for _ in range(5)] - updated_chatbot = await update_chatbot_svc.run(chatbot=persisted_chatbot) - persisted_chatbot.last_updated_at = updated_chatbot.last_updated_at - assert updated_chatbot == persisted_chatbot diff --git a/uv.lock b/uv.lock index f4b6cb4..e90f9d4 100644 --- a/uv.lock +++ b/uv.lock @@ -517,8 +517,8 @@ wheels = [ [[package]] name = "huesoporro" -version = "0.3.0" -source = { editable = "." } +version = "0.2.9" +source = { virtual = "." } dependencies = [ { name = "aiosqlite" }, { name = "caribou" }, @@ -538,10 +538,6 @@ dependencies = [ ] [package.dev-dependencies] -cli = [ - { name = "typer" }, - { name = "yt-dlp" }, -] dev = [ { name = "mypy" }, { name = "polyfactory" }, @@ -549,7 +545,6 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-coverage" }, { name = "ruff" }, - { name = "types-pyyaml" }, ] [package.metadata] @@ -572,10 +567,6 @@ requires-dist = [ ] [package.metadata.requires-dev] -cli = [ - { name = "typer", specifier = ">=0.15.1" }, - { name = "yt-dlp", specifier = ">=2025.1.26" }, -] dev = [ { name = "mypy", specifier = ">=1.13.0" }, { name = "polyfactory", specifier = ">=2.18.1" }, @@ -583,7 +574,6 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "pytest-coverage", specifier = ">=0.0" }, { name = "ruff", specifier = ">=0.8.3" }, - { name = "types-pyyaml", specifier = ">=6.0.12.20241230" }, ] [[package]] @@ -1377,15 +1367,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - [[package]] name = "six" version = "1.17.0" @@ -1469,30 +1450,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/be/49d93b0e13dad69a636e550a7b96a5208af9a91100f9b142a363882e0c4c/twitchio-2.10.0-py3-none-any.whl", hash = "sha256:7aa0b6950dad90feeb04b03fd10d3e4292fa8a7c2e7aea6b2fd6686bc5425fb2", size = 143761 }, ] -[[package]] -name = "typer" -version = "0.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, -] - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20241230" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 }, -] - [[package]] name = "typing-extensions" version = "4.12.2" @@ -1731,12 +1688,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 }, { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, ] - -[[package]] -name = "yt-dlp" -version = "2025.2.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/36/ef300ba4a228b74612d4013b43ed303a0d6d2de17a71fc37e0b821577e0a/yt_dlp-2025.2.19.tar.gz", hash = "sha256:f33ca76df2e4db31880f2fe408d44f5058d9f135015b13e50610dfbe78245bea", size = 2929199 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/45/6d1b759e68f5363b919828fb0e0c167a1cd5003b5b7c74cc0f0c2096be4f/yt_dlp-2025.2.19-py3-none-any.whl", hash = "sha256:3ed218eaeece55e9d715afd41abc450dc406ee63bf79355169dfde312d38fdb8", size = 3186543 }, -]