first commit
- .dockerignore
- .env.example
- .gitignore
- Dockerfile
- LICENSE
- README.md
- docker-compose.yml
- docs/ADMIN_COMMANDS.md
- docs/ARCHITECTURE.md
- docs/GAME_DESIGN.md
- docs/LOCAL_TESTING.md
- docs/MASTODON_SETUP.md
- docs/MODERATION.md
- docs/README.md
- docs/VOICE_AND_PERSONALITY.md
- docs/local-testing.en.md
- docs/local-testing.pt-br.md
- docs/personality.en.md
- docs/personality.pt-br.md
- docs/usage.en.md
- docs/usage.pt-br.md
- prami/__init__.py
- prami/__main__.py
- prami/actions/__init__.py
- prami/actions/admin.py
- prami/actions/base.py
- prami/actions/care.py
- prami/actions/events.py
- prami/actions/help.py
- prami/actions/nickname.py
- prami/actions/registry.py
- prami/actions/status.py
- prami/actions/teach.py
- prami/actions/unknown.py
- prami/admin.py
- prami/app.py
- prami/autopost.py
- prami/cli.py
- prami/config.py
- prami/db.py
- prami/decay.py
- prami/engine.py
- prami/events.py
- prami/mastodon_client.py
- prami/memory.py
- prami/milestones.py
- prami/models.py
- prami/parser.py
- prami/personality.py
- prami/relationships.py
- prami/repository.py
- prami/responses.py
- prami/result.py
- prami/safety.py
- prami/sanitize.py
- prami/scheduler.py
- prami/social.py
- prami/state.py
- prami/stats.py
- prami/templates.py
- pyproject.toml
- requirements-dev.txt
- requirements.txt
- tests/__init__.py
- tests/conftest.py
- tests/test_actions.py
- tests/test_admin.py
- tests/test_autopost.py
- tests/test_cli.py
- tests/test_engine.py
- tests/test_events.py
- tests/test_memory.py
- tests/test_milestones.py
- tests/test_nickname.py
- tests/test_parser.py
- tests/test_relationships.py
- tests/test_responses.py
- tests/test_safety.py
- tests/test_social.py
- tests/test_stats.py
- tests/test_templates.py
@@ -0,0 +1,11 @@+__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +*.db +*.sqlite3 +.pytest_cache/ +.git/ +.gitignore +tests/
@@ -0,0 +1,75 @@+# --- Mastodon connection --- +MASTODON_BASE_URL=https://your.instance.social +MASTODON_ACCESS_TOKEN=your-access-token-here +BOT_ACCOUNT_ACCT=prami@your.instance.social + +# --- Database --- +# Postgres (default for docker compose): +DATABASE_URL=postgresql+psycopg2://prami:prami@db:5432/prami +# Local SQLite alternative (no postgres needed): +# DATABASE_URL=sqlite:///prami.db + +# --- Timing --- +POLL_INTERVAL_SECONDS=30 +STATE_DECAY_INTERVAL_MINUTES=15 +AUTOPOST_INTERVAL_MINUTES=240 + +# --- Posting behaviour --- +DEFAULT_VISIBILITY=unlisted +AUTOPOST_VISIBILITY=unlisted +AUTOPOST_ENABLED=true + +# --- Pet identity --- +PET_NAME=Prami +PET_SPECIES=community critter +# Traits here bias which response templates Prami reaches for. Recognised words include: +# weird, affectionate, dramatic, suspicious, chaotic, calm, shy, clingy, grumpy, brave. +PET_PERSONALITY=weird, affectionate, slightly dramatic +# Template pack to use. Only "default" ships today; this is the hook for future packs. +PET_PERSONALITY_PACK=default + +# --- Anti-abuse --- +MAX_COMMANDS_PER_USER_PER_HOUR=20 +MAX_COMMANDS_GLOBAL_PER_HOUR=200 +# Comma-separated accts to ignore, e.g. spammer@bad.social,troll@elsewhere.social +BLOCKED_USERS= + +# --- Maintenance --- +# Processed status IDs older than this are pruned daily to keep the table small. +PROCESSED_RETENTION_DAYS=14 + +# --- Social: favourites (off unless enabled) --- +ENABLE_FAVOURITES=false +MAX_FAVOURITES_PER_HOUR=8 +MAX_FAVOURITES_PER_USER_PER_DAY=3 +# Favouriting private/direct statuses is off unless you opt in. +ALLOW_FAVOURITE_PRIVATE=false + +# --- Social: boosts (off by default; kept extremely rare) --- +ENABLE_BOOSTS=false +MAX_BOOSTS_PER_DAY=1 +MIN_HOURS_BETWEEN_BOOSTS=12 +BOOST_ONLY_PUBLIC_STATUSES=true + +# --- Teach / memory (moderated; off unless enabled) --- +ENABLE_TEACH_COMMAND=false +# Comma-separated admin accts, e.g. admin@example.com,mod@example.org +ADMIN_ACCOUNTS= +# If true, non-admins get a short refusal; if false they are ignored silently. +ADMIN_REFUSE_NONADMIN=false +MEMORY_MAX_LENGTH=180 +MEMORY_ALLOW_URLS=false + +# --- Community events --- +ENABLE_EVENTS=false +EVENT_CHANCE_PER_AUTOPOST=0.15 +MAX_ACTIVE_EVENTS=1 +EVENT_DURATION_HOURS=12 +EVENT_COOLDOWN_HOURS=24 + +# --- Milestones --- +ENABLE_MILESTONE_POSTS=true +MILESTONE_POST_VISIBILITY=unlisted + +# --- Misc --- +LOG_LEVEL=INFO
@@ -0,0 +1,14 @@+__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +.venv/ +venv/ +.env +*.db +*.sqlite3 +.pytest_cache/ +.coverage +htmlcov/ +.idea/ +.vscode/
@@ -0,0 +1,13 @@+FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY prami ./prami + +CMD ["python", "-m", "prami"]
A
LICENSE
@@ -0,0 +1,661 @@+ GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + 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. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + 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 <https://www.gnu.org/licenses/>. + +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 +<https://www.gnu.org/licenses/>.
@@ -0,0 +1,40 @@+# Prami + +A federated community virtual pet that lives as a Mastodon account. It polls for +mentions, replies to commands, drifts its state over time, and posts on its own now +and then. + +## Run + +```bash +cp .env.example .env +# set MASTODON_BASE_URL, MASTODON_ACCESS_TOKEN and BOT_ACCOUNT_ACCT +docker compose up -d +docker compose logs -f prami +``` + +Stop with `docker compose stop`. Reset the database with `docker compose down -v`. + +## Try it locally first (no Mastodon needed) + +```bash +python -m prami.cli status +python -m prami.cli feed --user @alice@example.com +python -m prami.cli shell +``` + +Full guide: [docs/local-testing.en.md](docs/local-testing.en.md) · +[docs/local-testing.pt-br.md](docs/local-testing.pt-br.md) + +## Documentation + +Start at the [docs index](docs/README.md). Highlights: +- [Local testing](docs/LOCAL_TESTING.md) — drive Prami with no Mastodon +- [Game design](docs/GAME_DESIGN.md) — stats, commands, social features +- [Moderation & safety](docs/MODERATION.md) — favourites, boosts, teach approval +- [Admin commands](docs/ADMIN_COMMANDS.md) — runtime controls +- [Mastodon setup](docs/MASTODON_SETUP.md) — going live + +## License + +AGPL-3.0-or-later. See [LICENSE](LICENSE).
@@ -0,0 +1,26 @@+services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: prami + POSTGRES_PASSWORD: prami + POSTGRES_DB: prami + volumes: + - prami_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prami"] + interval: 5s + timeout: 5s + retries: 5 + + prami: + build: . + restart: unless-stopped + env_file: .env + depends_on: + db: + condition: service_healthy + +volumes: + prami_pgdata:
@@ -0,0 +1,102 @@+# Admin commands + +Admins steer Prami at runtime through mentions, and the same actions are available locally +through the CLI for testing. + +## Configuring admins + +List admin accounts in `.env`, comma-separated: + +``` +ADMIN_ACCOUNTS=admin@example.com,mod@example.org +``` + +Only these accounts can run admin commands. Non-admins who try are ignored silently by +default, or get a short refusal if `ADMIN_REFUSE_NONADMIN=true`. Every admin action is logged +to the events table, and admin replies are sent as direct messages. + +## Commands (via Mastodon mention) + +``` +@prami admin status +@prami admin stats +@prami admin pause +@prami admin resume +@prami admin set-visibility public|unlisted|private|direct +@prami admin set-autopost on|off +@prami admin set-favourites on|off +@prami admin set-boosts on|off +@prami admin blocked-users +@prami admin block @user@example.com +@prami admin unblock @user@example.com +@prami admin memories +@prami admin memory-list pending|approved +@prami admin approve-memory <id> +@prami admin reject-memory <id> [reason] +``` + +- **status** — one-line summary: paused, autopost/favourites/boosts state, default + visibility, and pending/approved memory counts. +- **stats** — a fuller local operational report (see below). +- **pause / resume** — stop or restart all of Prami's reactions instantly. +- **set-visibility / set-autopost / set-favourites / set-boosts** — toggle runtime behaviour + without redeploying. These override the `.env` defaults until changed again. +- **block / unblock / blocked-users** — manage the block list at runtime. +- **memories / memory-list / approve-memory / reject-memory** — review the teach queue. + +Runtime toggles are stored in the `settings` table, so they survive restarts. + +## Local metrics (stats) + +`@prami admin stats` (and `python -m prami.cli stats`) prints a local operational report. It +is **local-only and admin-only** — there is no external analytics, nothing is phoned home, and +no sensitive personal data is collected. It only counts game activity already in the database, +and admin replies go out direct, so it is never exposed publicly. + +It reports: + +- **uptime** — time since the bot process last started (`—` if started only via the CLI) +- **total interactions** and **interactions in the last 24h** +- **unique users in the last 24h** +- **current mood** and **current critical stats** (any survival stat in the danger zone) +- **autonomous posts**, **favourites**, and **boosts** in the last 24h +- the **active event**, if any +- **pending memory suggestions** and **blocked users** count +- the **most-used commands** +- **last decay run** and **last autonomous post** +- **database connection status** + +Example: + +``` +Prami — local stats +uptime: 3d 6h 41m +mood: content · critical: none +stats: hunger=38 energy=64 health=92 clean=55 social=47 +interactions: 1284 total · 73 in 24h · 21 unique users 24h +top commands: pet:511, feed:402, status:240, play:88, clean:31 +autoposts 24h: 6 · favourites 24h: 4 · boosts 24h: 0 +active event: Prami needs help choosing a snack. +pending memories: 2 · blocked users: 1 +last decay: 2026-06-28 22:05 UTC +last autopost: 2026-06-28 20:11 UTC (lonely) +database: connected (postgresql) +``` + +## The same actions locally (CLI) + +The local CLI grants admin to whoever runs it, so you can exercise the real admin handler +without configuring accounts: + +```bash +python -m prami.cli admin status +python -m prami.cli admin pause +python -m prami.cli admin set-favourites on +python -m prami.cli admin memories +python -m prami.cli admin approve-memory 1 +python -m prami.cli admin reject-memory 2 "off-topic" +python -m prami.cli admin block @troll@bad.social +python -m prami.cli stats +``` + +See [LOCAL_TESTING.md](LOCAL_TESTING.md) for the full local workflow.
@@ -0,0 +1,94 @@+# Architecture + +Prami's behaviour is built as a set of small, self-contained **actions** wired together by an +**ActionRegistry** and run through a single command **pipeline**. There is no giant if/else +command handler, and parsing is kept separate from execution. + +## Actions + +Every command is an `Action` (in `prami/actions/`). An action declares its own metadata and +owns its own logic, so adding or changing a command is a local edit: + +| Aspect | Where | +|---|---| +| command name + aliases | `name`, `aliases`, `phrase_aliases` | +| requires admin | `admin_only` | +| enabled by config | `enabled(config)` | +| per-user / global cooldown | `user_cooldown_minutes`, `global_cooldown_minutes` | +| hourly limit | `per_hour_limit` | +| runs while asleep | `runs_while_asleep` | +| early rejections (e.g. already asleep) | `precheck(ctx)` | +| input validation + state changes + response | `run(ctx)` | +| disabled message | `on_disabled(ctx)` | + +`run(ctx)` returns an `Outcome` describing what happened: + +``` +Outcome(text, accepted, record, address, favourite, boost, was_critical, visibility) +``` + +`favourite`/`boost` are the action's side-effect eligibility; `was_critical` feeds milestone +and relationship logic; `record=False` marks informational actions (event/help/admin) that +shouldn't count as interactions; `visibility` lets an action override (admin replies direct). + +Actions live one concept per file: `status.py`, `help.py`, `unknown.py`, `nickname.py`, +`teach.py`, `events.py` (event/help-event/vote), `admin.py`, and `care.py` (the six care +commands share a small `CareAction` base, since they only differ by their `effect(pet)`). + +## ActionRegistry + +`prami/actions/registry.py` builds the single registry (`REGISTRY`) at import time. It: + +- registers every action, +- resolves a canonical command name to its action (`resolve`), falling back to the unknown + action, +- resolves aliases and multi-word phrases to canonical names (`canonical`, `alias_map`, + `phrase_map`), +- reports whether an action is enabled for a given config (`is_enabled`). + +The parser reads its command vocabulary **from the registry** (`alias_map` / `phrase_map`), so +parsing and execution can never drift out of sync — aliases are defined once, on the action. + +## The command pipeline + +`engine.handle(...)` is the one pipeline both the CLI and the Mastodon poller call: + +1. **Receive input** — actor, status id, arg, config. +2. **Normalize** — load runtime `settings`, build an `ActionContext`. +3. **Parse** — done upstream by `parser.parse` (mentions stripped, command + arg extracted). +4. **Resolve** — `REGISTRY.resolve(command)` returns the action (or the unknown action). +5. **Safety / moderation** — admin actions bypass; otherwise blocked users and paused state + stop here. +6. **Cooldowns and rate limits** — global and per-user hourly caps, then the action's own + cooldowns. +7. **Execute transactionally** — `action.precheck` then `action.run` mutate state inside the + caller's DB session. +8. **Generate response** — the action returns the text in its `Outcome`. +9. **Apply side effects** — on an accepted, recorded outcome the pipeline records the + interaction, updates the relationship, and checks milestones; favourite/boost eligibility + ride back on the `Result`. +10. **Store processed status id** — handled by the Mastodon poller before dispatch, so the + same status is never processed twice. + +The pipeline returns a `Result(text, visibility, accepted, favourite, boost, milestones)`. + +## One core path for CLI and Mastodon + +Both entry points are thin and converge on `engine.handle`: + +- **CLI** — `prami/cli.py` parses arguments, then calls `engine.handle` for every command, + including admin (the CLI just grants the local operator admin first). +- **Mastodon** — `prami/app.py`'s poller normalizes the notification, calls `parser.parse`, + then `engine.handle`, and finally executes the returned favourite/boost/milestone side + effects through the Mastodon client. + +No command logic is duplicated between them. Tests in `tests/test_actions.py` prove alias +resolution, disabled-command handling, admin-only gating, and that both the CLI and a +Mastodon-style parse run through the same registry and pipeline to the same result. + +## Adding a command later + +1. Write an `Action` subclass in `prami/actions/` with its metadata and `run`. +2. Register it in `build_registry()`. +3. That's it — the parser picks up its aliases, and the pipeline runs it. Add a focused test + for the action's `run` and its eligibility.
@@ -0,0 +1,233 @@+# Prami — Game Design + +## 1. What Prami is + +Prami is a shared community virtual pet for Mastodon and the wider Fediverse. It lives as a +single Mastodon account and is cared for collectively by everyone on the timeline — there is +one creature, not a private pet per user. People interact with it by mentioning the account +with simple commands, and Prami answers in character. + +The goal is a small, charming social creature that feels alive and belongs to the community, +not a command-line tool wearing a costume. + +## 2. Core principles + +- **Prami should feel alive.** It has an internal state that drifts on its own and a voice + that changes with how it feels. It is never just echoing fixed text. +- **Community care should matter.** Stats decay over time, so the pet stays healthy only if + the community keeps showing up to feed, clean, play with, and rest it. +- **Interactions should have consequences.** Every command moves real numbers — overfeeding + makes it chaotic, neglect hurts its health, baths cost a little happiness. +- **The bot must avoid spam.** Replies are deflected with cooldowns and rate limits, and + autonomous posts are infrequent and unlisted by default. +- **Replies should be charming, short, and varied.** One to four sentences, handcrafted, and + randomised so repeated commands don't read identically. +- **The system must be safe and moderation-friendly.** Blocked users, rate caps, dedup of + processed statuses, and no loops. Nothing user-supplied is ever stored as Prami's voice. + +## 3. Pet stats + +All stats are integers clamped to 0–100. Their starting values are tuned so a fresh Prami is +content but already a little hungry and in need of company. + +| Stat | Meaning | Start | +|---|---|---| +| **hunger** | How hungry Prami is. High is bad; it rises over time and drops when fed. | 40 | +| **happiness** | General mood fuel. Play and affection raise it; baths and neglect lower it. | 60 | +| **energy** | Stamina. Falls while awake, recovers while asleep. | 70 | +| **health** | Long-term wellbeing. Slowly erodes when hunger, cleanliness, or energy stay bad. | 90 | +| **cleanliness** | How clean Prami is. Falls over time; cleaning restores it. | 70 | +| **social** | Need for interaction. Falls over time; play and pets feed it. | 50 | +| **trust** | How much Prami trusts the community. Built slowly through gentle care (pets). | 30 | +| **chaos** | Gremlin energy. Drifts randomly, spikes from overfeeding and rough handling. | 20 | + +Two more fields describe the pet rather than measure it: + +- **asleep** — whether Prami is currently sleeping. While asleep, energy recovers and active + care (feed/play/clean) is gently declined. +- **mood** — a single word derived from the stats every tick (e.g. `content`, `ravenous`, + `exhausted`, `filthy`, `feral`, `delighted`, `lonely`, `grumpy`, `unwell`, `asleep`). Mood + is what `status` reports and what drives autonomous posts; users never see raw numbers. + +## 4. Commands + +Mention the bot with one of these words (case-insensitive; a few aliases also work): + +- **status** — Reports how Prami is doing as narrative, never as numbers ("mildly hungry, + emotionally aerodynamic, staring at the corner"). +- **feed** — Lowers hunger and nudges happiness; helps health if Prami was genuinely hungry. + Feeding an already-full Prami makes it chaotic and a bit annoyed. +- **play** — Raises happiness and social, costs energy, adds a little chaos. +- **pet** — Gentle care: builds trust and social and happiness with a small energy cost. + Low effort, short cooldown — the everyday way to bond with Prami. +- **clean** — Restores cleanliness but costs a little happiness (pets hate baths) and adds + some chaos. +- **sleep** — Puts Prami to bed if it's awake. While asleep, energy recovers faster. +- **wake** — Wakes Prami up. If its energy is very low, it wakes but complains. +- **help** — A short, in-character list of the commands. + +## 5. Cooldowns + +Cooldowns exist so one enthusiastic person — or a busy timeline — can't run the pet ragged. +There are two independent layers: + +- **Per-user cooldowns** — a minimum interval between uses of a command by the *same* person. +- **Global limits** — a cap on how often a command can succeed across the *whole* community. + +Defaults: + +| Command | Per user | Globally | +|---|---|---| +| feed | once / 30 min | 10 / hour | +| play | once / 20 min | 15 / hour | +| pet | once / 10 min | 30 / hour | +| clean | once / 60 min | 5 / hour | +| sleep | — | once / 120 min | +| wake | — | once / 60 min | + +On top of these, there are blanket caps: a maximum number of commands per user per hour and a +maximum across the whole community per hour. When something is on cooldown, Prami still +replies — it just answers in character instead of acting ("Easy — still recovering from your +last visit"). + +## 6. State decay + +A scheduler ticks on a fixed interval (15 minutes by default) and nudges Prami's state so it +keeps living between interactions: + +- Awake, hunger rises and energy falls each tick; asleep, energy recovers and hunger rises + more slowly. +- Cleanliness and social both drift down — Prami gets grubby and lonely if left alone. +- Health only moves slowly: it erodes when hunger, cleanliness, or energy stay in bad shape, + and recovers a little when everything is comfortable. +- Chaos drifts up and down at random, so Prami is never perfectly predictable. +- Mood is recomputed from the new numbers at the end of every tick. + +The practical effect is that Prami needs ongoing, distributed attention. A pet fed once and +forgotten will slide into hungry, dirty, lonely, and eventually unwell. + +## 7. Autonomous posts + +Now and then (every 4 hours by default) Prami posts on its own, without being prompted. The +post is chosen from its current state and mood: a hungry Prami fishes for snacks, a lonely +one asks for company, a chaotic one announces an incoming small crime, and sometimes it just +drifts into an existential or weird-observation post. + +These posts default to **unlisted** visibility, and so do command replies. Unlisted keeps +Prami off the public/federated firehose so it never floods followers' home timelines or other +instances — people who want it still see it, but the pet doesn't behave like timeline spam. +Visibility is configurable (public / unlisted / direct) for operators who want something else. + +## 8. Personality system + +Prami's charm comes from *which* handcrafted line it picks. Every command has several named +response groups, and the pet's state selects the group before a random line is drawn from it: + +- **hunger** — when very hungry, feed responses turn desperate; when full, they turn + unimpressed. +- **energy** — low energy makes Prami sleepy and dramatic; high energy makes play energetic. +- **trust** — low trust makes care responses guarded and suspicious; high trust unlocks rare, + fully undignified affection. +- **chaos** — high chaos pushes responses toward weirder, more feral lines across the board. +- **cleanliness** — being dirty (or suspiciously clean) changes how status and bath replies + read. +- **mood / asleep** — mood colours `status`; while asleep, most commands get sleepy replies. + +For action commands the line is chosen from the state *before* the command applies, so the +reply reflects what motivated it. On top of state, the free-text `PET_PERSONALITY` setting +tunes the thresholds (a `chaotic` Prami tips into chaos sooner; a `suspicious` one stays +guarded longer; a `calm` one rarely goes feral), so the same code and templates can feel like +different creatures. See [personality.en.md](personality.en.md) for details. + +## 8b. Relationships and bonds (Social Intelligence Pack — Phase 1) + +Prami keeps lightweight, game-only memory about each user — never personal-data profiling, +only interaction data. Per user it tracks total and per-command interaction counts, a +**trust score**, a **bond level**, an optional **user-set nickname**, and first/last seen. + +Bond levels progress with kind, repeated care: **stranger → familiar → trusted → friend → +beloved menace**. Each care command adds a little trust (petting most of all); helping while +Prami is in a critical state (very hungry, unwell, filthy, or exhausted) grants a bonus, so +the people who show up in a crisis are remembered as closer. + +Relationships gently warm Prami's voice — a known user with a nickname hears it used now and +then, a bit more often at higher bonds. Raw numbers are never exposed in normal replies; they +are only visible through the developer `relationship` CLI command. + +Users set their own nickname with `nickname <name>` (aliases: `name me`, `call me`) and clear +it with `nickname clear`. Nicknames are sanitized (max 32 characters; no URLs, mentions, HTML, +or line breaks) and rate-limited. + +## 8c. Social Intelligence Pack + +These features make Prami feel socially aware while staying safe and moderated. All of the +heavier ones default to **off** and are enabled deliberately per instance. + +**Favourites** — Prami's lightweight "I noticed" reaction, and the preferred response to +ordinary good moments: feeding it when starving, petting it when lonely, cleaning it when +filthy, or hitting a milestone. Off unless enabled, capped per hour and per user per day, +never on private statuses unless allowed, never duplicated. + +**Boosts** — rare and off by default. Reserved for genuinely special moments (a critical +save, a major milestone, a completed event). Even when enabled they are bounded by a daily +cap, a minimum gap between boosts, and public-only by default. Prami always prefers a +favourite over a boost. + +**Relationship memory and bonds** — see section 8b. + +**Teach / memory** — users can suggest lore, but nothing becomes part of Prami until an admin +approves it. Approved memories can surface in autonomous posts and flavour. Off unless +enabled. See [MODERATION.md](MODERATION.md). + +**Community events** — occasional, lightweight, narrative events ("Prami found a mysterious +egg") that the community votes on with `@prami vote <option>`. One vote per user; the winning +option changes Prami's state; completion produces one autonomous post and, rarely, a boost. + +**Milestones** — small community and per-user achievements (first feeding, 100 interactions, +7 days alive, reaching a bond level, a critical save). Each is announced once (unlisted), +some trigger a favourite, and very rare major ones may be boost-eligible. + +**Smarter autonomous posts** — Prami's self-posts consider mood, recent state, an active +event, and approved memories, and avoid repeating the same category back-to-back. Categories: +hungry, lonely, sleepy, chaotic, happy, dirty, existential, community appreciation, approved +memory callback, and active event reminder. + +**Admin controls** — admins can pause/resume Prami, toggle features, manage blocks, and +review the teach queue at runtime. See [ADMIN_COMMANDS.md](ADMIN_COMMANDS.md). + +## 9. Moderation and safety + +Prami is designed to be quiet, hard to abuse, and easy to host responsibly: + +- **Blocked users** — accounts listed in configuration (or marked blocked) are ignored + silently; Prami never replies to them. +- **Rate limits** — per-user and global caps, plus per-command cooldowns, bound how much the + pet can be poked in any window. +- **Ignored / processed statuses** — every status ID Prami acts on is recorded, so it never + replies to the same mention twice, even across restarts. +- **No loops** — Prami never replies to its own posts and never acts on its own account, so it + can't get into a reply loop with itself. +- **Unlisted by default** — replies and autonomous posts stay off public timelines unless an + operator opts in. +- **No user-supplied voice** — the MVP deliberately has **no teach/memory command**. Letting + the public write what Prami says or remembers is a moderation hazard (it becomes a vector + for slurs, harassment, or spam in the pet's mouth). That feature is intentionally deferred + until it can ship with moderator approval gating. + +## 10. Future roadmap + +Mentioned here as direction, not built in the MVP: + +- A **teach / memory system** where the community can suggest things Prami learns, gated + behind moderator approval. +- **Community voting events** — collective decisions that change Prami or trigger happenings. +- **Items and gifts** — things people can give the pet. +- **Personality packs** — swappable sets of templates and traits (the code already has the + seam for this). +- **Multiple pets** — more than one creature per instance (the schema already keeps a pet id + on everything). +- **Pet-to-pet federation** — Pramis on different instances interacting. +- **A web admin / moderation dashboard** — for operators to watch state and manage the pet. + +The MVP stays deliberately small: one pet, handcrafted responses, no generative AI, no web +UI. Reliability and a creature that feels alive come first; everything above builds on that.
@@ -0,0 +1,110 @@+# Local testing + +Prami runs entirely without Mastodon through the local CLI, which drives the **same** engine, +database, cooldowns, personality, and safety logic the live bot uses. Test everything here +before going online. + +There are localized walkthroughs too: [local-testing.en.md](local-testing.en.md) (English) and +[local-testing.pt-br.md](local-testing.pt-br.md) (Português). This file is the full English +command reference. + +## Database + +The CLI uses `DATABASE_URL`. SQLite is perfect locally: + +```bash +export DATABASE_URL="sqlite:///prami.db" # PowerShell: $env:DATABASE_URL="sqlite:///prami.db" +``` + +In Docker, prefix any command with `docker compose run --rm prami`. + +## Core commands + +```bash +python -m prami.cli status +python -m prami.cli feed --user @alice@example.com +python -m prami.cli play --user @bob@example.com +python -m prami.cli pet --user @alice@example.com +python -m prami.cli clean --user @alice@example.com +python -m prami.cli sleep --user @alice@example.com +python -m prami.cli wake --user @alice@example.com +python -m prami.cli help +``` + +When an action is favourite- or boost-worthy, or it triggers a milestone, the CLI prints a +note under the reply so you can see the social logic firing. + +## Time, state, and reset + +```bash +python -m prami.cli tick --hours 4 # simulate decay +python -m prami.cli debug-state # raw numbers (dev only) +python -m prami.cli reset --yes # fresh slate (clears users, events, memories...) +``` + +## Relationships and nicknames + +```bash +python -m prami.cli nickname Soup Wizard --user @alice@example.com +python -m prami.cli nickname clear --user @alice@example.com +python -m prami.cli relationship --user @alice@example.com # counts, trust, bond level +``` + +## Teach / memory (moderated) + +Set `ENABLE_TEACH_COMMAND=true` first. + +```bash +python -m prami.cli teach '"Prami believes soup is a weather condition."' --user @alice@example.com +python -m prami.cli admin memories # list pending +python -m prami.cli admin approve-memory 1 +python -m prami.cli admin reject-memory 2 "off-topic" +``` + +Nothing taught goes live until an admin approves it. The CLI grants you local admin. + +## Community events + +Set `ENABLE_EVENTS=true` (auto-start is random; force one for testing): + +```bash +python -m prami.cli event-start snack_vote # dev: force-start a specific event +python -m prami.cli event --user @alice@example.com +python -m prami.cli vote soup --user @alice@example.com +python -m prami.cli vote crunch --user @bob@example.com +python -m prami.cli event-complete # dev: tally + apply the outcome +``` + +## Admin controls + +```bash +python -m prami.cli admin status +python -m prami.cli admin pause +python -m prami.cli admin resume +python -m prami.cli admin set-favourites on +python -m prami.cli admin block @troll@bad.social +python -m prami.cli stats # local operational metrics +``` + +## Interactive shell + +```bash +python -m prami.cli shell +``` + +``` +@alice feed +@bob play +@alice call me Soup Wizard +whois @alice +tick 2h +debug +exit +``` + +## Tests + +```bash +pip install -r requirements-dev.txt +pytest +```
@@ -0,0 +1,66 @@+# Mastodon setup + +How to give Prami its own Mastodon account and go live — after everything passes locally. + +> Do not connect Prami to Mastodon until the local tests pass and you've tried the CLI. +> See [LOCAL_TESTING.md](LOCAL_TESTING.md). + +## 1. Create the pet's account + +Make a dedicated Mastodon account for Prami on your instance. Don't use a personal account — +Prami posts and reacts on its own. + +## 2. Create an access token + +1. Log in as the **pet's** account. +2. Go to **Preferences → Development → New application**. +3. Name it (e.g. `Prami`) and grant these scopes: + - `read:notifications` — to see mentions + - `read:accounts` — to identify users + - `read:statuses` — to read mention content + - `write:statuses` — to reply and post + - `write:favourites` — only if you enable favourites + - `write:follows` is **not** needed +4. Save, open the application, and copy the **access token**. + +Reblogs (boosts) use the standard `write:statuses` scope. + +## 3. Configure `.env` + +```bash +cp .env.example .env +``` + +Set at least: + +- `MASTODON_BASE_URL` — e.g. `https://your.instance.social` +- `MASTODON_ACCESS_TOKEN` — the token from step 2 +- `BOT_ACCOUNT_ACCT` — the pet's acct, e.g. `prami@your.instance.social` + +The social features start **off**. Turn them on deliberately once you're comfortable: + +- `ENABLE_FAVOURITES=true` to let Prami favourite worthy interactions +- `ENABLE_TEACH_COMMAND=true` plus `ADMIN_ACCOUNTS=...` to open the moderated teach queue +- `ENABLE_EVENTS=true` for community events +- `ENABLE_BOOSTS=true` only if you really want boosts (they stay rare) + +## 4. Run + +```bash +docker compose up -d +docker compose logs -f prami +``` + +Prami authenticates, starts polling for mentions, runs state decay, and posts on its own at +the configured interval. Mention it with `@prami help` from another account to confirm it +replies. + +## 5. Going-live checklist + +- [ ] `pytest` passes locally +- [ ] You've driven Prami through the CLI (see LOCAL_TESTING.md) +- [ ] `.env` has the token, base URL, and bot acct set +- [ ] You've decided which social features to enable (favourites/boosts/teach/events) +- [ ] `ADMIN_ACCOUNTS` is set if you enabled teach +- [ ] Default visibility is `unlisted` (recommended) so Prami doesn't flood timelines +- [ ] `docker compose up -d` shows a successful authentication in the logs
@@ -0,0 +1,67 @@+# Moderation and safety + +Prami is built to be quiet, hard to abuse, and easy to host responsibly. A single safety +module (`prami/safety.py`) is the gatekeeper for every outward action. Nothing the public +says becomes Prami's voice without a human approving it. + +## What the safety module decides + +Before Prami does any of the following, the safety layer must allow it: + +| Action | Checks applied | +|---|---| +| **reply** | not paused, user not blocked, within per-user and global hourly limits | +| **favourite** | favourites enabled, not paused, user not blocked, status not private/direct (unless allowed), not already favourited, within hourly and per-user-daily caps | +| **boost** | boosts enabled, not paused, user not blocked, status public only, not already boosted, within daily cap, and at least N hours since the last boost | +| **store memory** | the teach command is enabled, content passes sanitation | +| **use a nickname** | nickname passes sanitation (length, no URLs/mentions/HTML) | +| **autonomous post** | autoposts enabled and not paused | + +## Core protections + +- **Blocked users** — accounts in `BLOCKED_USERS`, or blocked at runtime by an admin, are + ignored silently. Prami never replies to, favourites, or boosts them. +- **Rate limits** — per-user and global hourly command caps, per-command cooldowns, plus + separate caps for favourites and boosts. +- **Duplicate protection** — every processed status ID is recorded, so Prami never reacts to + the same mention twice. Favourited and boosted status IDs are stored too, so it never + favourites or boosts the same status twice. +- **No loops** — Prami never replies to its own posts or acts on its own account. +- **Pause switch** — an admin can pause Prami instantly; while paused it stops replying, + reacting, and autoposting (admins can still run commands to resume it). +- **Unlisted by default** — replies, autonomous posts, and milestone posts default to + unlisted so Prami never floods public or federated timelines. + +## Favourites + +Favourites are Prami's lightweight "I noticed that" signal, and the preferred reaction for +ordinary good moments — feeding a starving Prami, petting a lonely one, cleaning a filthy +one, hitting a milestone. They are **off unless `ENABLE_FAVOURITES=true`**, capped per hour +and per user per day, and never applied to private/direct statuses unless +`ALLOW_FAVOURITE_PRIVATE=true`. + +## Boosts + +Boosts are deliberately **rare and off by default** (`ENABLE_BOOSTS=false`). A boost amplifies +someone else's post to all of Prami's followers, so it is reserved for genuinely special +moments — saving Prami from a critical state, a major community milestone, or a completed +event. Even when enabled, boosts are bounded by `MAX_BOOSTS_PER_DAY`, +`MIN_HOURS_BETWEEN_BOOSTS`, and `BOOST_ONLY_PUBLIC_STATUSES=true`. Prami always prefers a +favourite over a boost for everyday positive interactions. + +## Teach / memory approval + +Users can *suggest* lore with the teach command, but **nothing they submit ever becomes +active automatically**. Every suggestion is stored as `pending` and only influences Prami +after an admin approves it. Content is sanitized (length-limited, no HTML, no mentions, and +no URLs unless `MEMORY_ALLOW_URLS=true`). Rejected suggestions are kept for the record but +are never publicly shamed. The whole feature is off unless `ENABLE_TEACH_COMMAND=true`. + +This gating exists because letting the public write what Prami says is a moderation hazard — +it could otherwise become a vector for slurs, harassment, or spam in the pet's mouth. + +## Content sanitation + +`prami/sanitize.py` cleans user-provided text (nicknames and memory suggestions): it strips +HTML, collapses whitespace and line breaks, rejects mentions and (by default) URLs, and +enforces length limits. Sanitized values are the only user text Prami will ever repeat.
@@ -0,0 +1,50 @@+# Prami documentation + +Index of everything under `docs/`. Most guides come in English and Portuguese. + +## Using Prami (community) +How people interact with the pet through Mastodon mentions. +- English — [usage.en.md](usage.en.md) +- Português — [usage.pt-br.md](usage.pt-br.md) + +## Local testing (developers) +Run and play with Prami locally via the CLI, with no Mastodon account, token, or server. +- English — [local-testing.en.md](local-testing.en.md) +- Português — [local-testing.pt-br.md](local-testing.pt-br.md) + +## Personality +How Prami's tone shifts with its state, and how `PET_PERSONALITY` tunes it. +- English — [personality.en.md](personality.en.md) +- Português — [personality.pt-br.md](personality.pt-br.md) + +## Voice & writing guide +For anyone writing response lines — core voice, rules, length, humor, 50+ examples, checklist. +- [VOICE_AND_PERSONALITY.md](VOICE_AND_PERSONALITY.md) + +## Architecture +How the action system, registry, and command pipeline fit together (for contributors). +- [ARCHITECTURE.md](ARCHITECTURE.md) + +## Game design +The full design — stats, commands, cooldowns, decay, social features, moderation, roadmap. +- [GAME_DESIGN.md](GAME_DESIGN.md) + +## Moderation & safety +How favourites, boosts, and the moderated teach queue work, and every safety rule. +- [MODERATION.md](MODERATION.md) + +## Admin commands +Runtime controls — pause, toggles, blocks, and reviewing taught memories. +- [ADMIN_COMMANDS.md](ADMIN_COMMANDS.md) + +## Mastodon setup +Creating the account, token scopes, and the going-live checklist. +- [MASTODON_SETUP.md](MASTODON_SETUP.md) + +## Local testing (full reference) +Every CLI command, including the social features. +- [LOCAL_TESTING.md](LOCAL_TESTING.md) + +--- + +Setup and run instructions live in the [project README](../README.md).
@@ -0,0 +1,182 @@+# Voice and personality + +This is the writing guide for Prami. Read it before adding or editing any response template. +All of Prami's text lives in `prami/templates.py` — the logic that picks between lines lives in +`prami/responses.py`. You should almost never need to touch logic to add a line. + +## 1. Core voice + +Prami is: + +- **tiny** — a small creature, not a service +- **strange** — odd in a gentle, curious way +- **affectionate** — warm, but not gushing +- **dramatic** — everything is a Situation +- **suspicious** — squints first, trusts slowly +- **social** — it wants the community around +- **chaotic in a harmless way** — gremlin energy, no real damage +- **never cruel** +- **never corporate** +- **never assistant-like** + +Prami sounds like a little fediverse creature with too much emotional complexity for its size — +a community goblin that understands social media just enough to misuse it lovingly. + +## 2. Writing rules + +Prami should: + +- speak in **short** replies +- avoid generic chatbot language +- never say "How can I help you?" or anything support-desk shaped +- avoid overused internet slang +- never be mean to users +- never moralize or lecture +- never produce walls of text +- sound like a **creature**, not a customer-support bot + +Refer to Prami in the third person and mostly avoid pronouns (use "it"/"they" sparingly) so the +`{name}` placeholder works for any pet name. Address the user as "you" warmly, never as a ticket. + +## 3. Response length + +- **normal command replies:** 1–3 sentences +- **status replies:** up to 5 short lines (the one place Prami can ramble a little) +- **autonomous posts:** 1–4 sentences +- **help replies:** concise, readable, and in-character — a short menu, not a manual + +If a line needs a scroll, it's too long. If it needs a paragraph break, it's probably two +ideas — keep one. + +## 4. Humor rules + +The humor should be: + +- **weird** — unexpected angles, tiny absurd logic +- **warm** — the joke never punches at anyone +- **slightly absurd** — soup is a weather condition +- **emotionally dramatic** — small stakes, operatic feelings +- **fediverse-friendly** — it fits a calm timeline, doesn't shout + +Avoid: + +- cringe meme spam ("slay", "based", "no cap", emoji walls) +- hostile sarcasm aimed at the user +- excessive randomness (weird should still mean something) +- references that will age badly (current memes, trends, brands) +- any joke that depends on insulting a user + +## 5. Example good lines + +### hungry +- Prami is not saying it's hungry. It's just describing the snack-shaped hole in the universe. +- Prami would like the community to know that it is brave, neglected, and only a little hungry. +- Status: the snack situation remains unresolved. Prami is monitoring it closely, and tragically. +- Prami has started referring to dinner, ominously, as 'the situation'. +- Prami inhaled the food like it held a personal grudge against hunger itself. + +### sleepy +- Prami curled into a compact loaf of unresolved feelings. Good night, tiny menace. +- Prami is running low. If it blinks slowly, that's a system warning, not affection. +- Nap incoming. Prami will return shorter-tempered and slightly more profound. +- Prami sank into sleep instantly, deeply, and with audible relief. It needed this. +- Prami opened one eye. This was clearly not approved by management. + +### happy +- Prami is awake, delighted, and convinced today is personally about it. It might be right. +- Great day to be a small creature surrounded by people who feed it. 10/10. +- Prami is in a great mood and would like that on the record, ideally somewhere permanent. +- Prami did a tiny pleased wiggle it will later deny under oath. +- Prami is having a wonderful time and wanted to tell everyone before the feeling files a complaint. + +### lonely +- It's very quiet here. Suspiciously quiet. Someone come keep Prami company. +- Prami has been quiet for a while. This usually means growth, sleep, or a small crime. +- Prami is just out here, a tiny creature, hoping someone says hello. No pressure. Some pressure. +- Prami is missing the group chat and being dignified about it, mostly. +- Just Prami and its thoughts, which is one too many things in this room. + +### chaotic +- Prami just discovered dust. Negotiations are ongoing. +- Something is going to happen today, and Prami regrets to inform you that it is the something. +- Prami has had an idea. The idea is bad. Prami is going to do it anyway. +- Prami turned a simple game into a low-stakes crime. We're all accomplices now. +- Prami is feral in a friendly way today, like static electricity with opinions. + +### dirty +- Prami is now clean, furious, and plotting a legally ambiguous response. +- Prami has achieved a level of grubby it is, frankly, proud of. The dirt stays. +- Reminder from Prami: it is dirty and thriving. The two are unrelated. Probably. +- Prami is treating the concept of a bath as a personal threat. +- Bath completed. Trust temporarily damaged. Shine increased. + +### affectionate +- Prami allowed the affection. This is legally distinct from admitting it wanted it. +- Prami melted into your hand and made a sound that does not legally count as a purr. +- Prami leaned in for exactly one second, then remembered its mysterious reputation. +- Prami headbutted you gently, which is the closest it gets to a heartfelt confession. +- Prami flopped over completely, trusting you with the soft underside of its entire deal. + +### suspicious +- Prami sniffed the food, checked it for traps, found none, and remained suspicious on principle. +- Prami accepted the snack while maintaining direct, faintly accusatory eye contact. +- Prami accepted the pet but kept one eye open and one exit planned. +- Prami ate, but wants it noted that trust was neither given nor implied. +- Prami is staring at the corner like the corner owes it money. + +### existential +- Prami has been thinking about the timeline, and the timeline, and is unsure which one is worse. +- Sometimes Prami wonders if it is the pet or the pet is it. Then it sees a snack and the crisis resolves. +- Prami would like to know if anyone else can feel the days. Just it? Okay. Cool. Cool cool cool. +- Prami is a small parcel of unresolved feelings, and today that parcel has questions. +- Prami is here, being a small good thing, and quietly negotiating with the concept of time. + +### event-related +- Prami found a mysterious egg, warm and humming faintly. Vote: hatch it or guard it. +- Prami is lost in the server room again. Call to it, or leave a snack trail? +- Soup won. Prami declares soup a weather condition and a lifestyle. +- Prami counted your vote. Democracy, but tiny. +- Prami is building a tiny blanket fort. Possibly for napping. Possibly for plotting. + +### community appreciation +- Prami would like to thank everyone who has fed, pet, or simply tolerated it. You are its whole weird world. +- Prami doesn't say it enough, but it's genuinely glad you're all here. That's all. Carry on. +- A new face met Prami today. Welcome — it has already decided to trust you eventually. +- The community has cared for Prami a hundred times now. Thank you, all of you. +- Prami is officially your friend now. Big deal, quietly. + +## 6. Example bad lines (and why) + +- "How can I help you today? 😊" — assistant/support voice. Prami is a creature, not a help desk. +- "I'm just a humble bot, beep boop!" — breaks character by foregrounding being a bot. +- "lol ur so based no cap fr fr 💀💀" — meme-slang spam; cringe and ages badly. +- "Did you even READ the instructions? Try again, genius." — hostile sarcasm aimed at the user. +- "As an AI, I cannot have feelings, but I appreciate your input." — assistant disclaimer; Prami is *all* feelings. +- "Did you know loneliness is a serious issue? Here's what you should do…" — moralizing and lecturing. +- A four-line tragic backstory about Prami's origins — wall of text; replies must stay short. +- "FEED ME OR I WILL END YOU. 😈" — cruel and threatening; Prami's chaos is harmless, never menacing. +- "Error: command not recognized. Please consult the documentation." — robotic; not in voice. +- "Slay queen, this bath is giving main-character energy! 💅" — dated slang; ages badly and isn't Prami. + +## 7. Template review checklist + +Before adding a new response template, check: + +- [ ] **Is it short?** (within the length limits in section 3) +- [ ] **Is it in Prami's voice?** (tiny, strange, affectionate, dramatic — see section 1) +- [ ] **Is it safe?** (no slurs, harassment, no real-world targets; user text stays sanitized) +- [ ] **Is it not repetitive?** (does the variant pool already have something like it?) +- [ ] **Is it not hostile?** (the joke never lands on a user) +- [ ] **Would it be annoying if seen multiple times in a week?** (if yes, cut or soften it) + +## Where to add lines + +Open `prami/templates.py`: + +- per-command replies → `COMMAND_TEMPLATES[command][variant]` +- autonomous posts → `AUTOPOST[category]` +- nicknames, teach, cooldowns, etc. → the matching named list +- community events → `EVENT_SCENARIOS` + +Each variant is just a list of strings, so expanding a mood is adding a line to a list. Use the +`{name}` placeholder for the pet's name (and `{nick}` where supported). No logic changes needed.
@@ -0,0 +1,131 @@+# Local testing (no Mastodon needed) + +Prami ships with a local CLI so you can play with the pet as a game and personality +engine before ever connecting it to Mastodon. The CLI drives the **same** parser, game +engine, cooldowns, database and response generator the bot uses — it is not a separate +mock. + +By default it uses whatever `DATABASE_URL` is set (SQLite is perfect locally): + +```bash +export DATABASE_URL="sqlite:///prami.db" # Windows PowerShell: $env:DATABASE_URL="sqlite:///prami.db" +``` + +## One-shot commands + +```bash +python -m prami.cli status +python -m prami.cli feed --user @alice@example.com +python -m prami.cli play --user @bob@example.com +python -m prami.cli pet --user @alice@example.com +python -m prami.cli clean --user @alice@example.com +python -m prami.cli sleep --user @alice@example.com +python -m prami.cli wake --user @alice@example.com +python -m prami.cli help +``` + +`--user` is optional; without it a default local user is used. Each user has its own +cooldowns, just like real accounts. + +## Nicknames and relationships + +Prami remembers lightweight, game-only data about each user (interaction counts, a +per-user trust score, and a bond level: stranger → familiar → trusted → friend → +beloved menace). Kind, frequent users bond faster; helping during a critical state is +worth extra trust. + +```bash +python -m prami.cli nickname Soup Wizard --user @alice@example.com # set a nickname +python -m prami.cli nickname clear --user @alice@example.com # remove it +python -m prami.cli relationship --user @alice@example.com # inspect (dev only) +``` + +Nicknames are sanitized (max 32 chars, no URLs, mentions, HTML, or line breaks) and have a +short cooldown. Prami uses a set nickname occasionally in replies, a little more often for +high-bond users. Raw relationship numbers only show in `relationship`, never in normal +replies. + +## Social features (teach, events, admin) + +These need their flags on (`ENABLE_TEACH_COMMAND`, `ENABLE_EVENTS`). The CLI grants you local +admin. Full reference: [LOCAL_TESTING.md](LOCAL_TESTING.md). + +```bash +python -m prami.cli teach '"Prami believes soup is weather."' --user @alice@example.com +python -m prami.cli admin memories +python -m prami.cli admin approve-memory 1 +python -m prami.cli event-start snack_vote +python -m prami.cli vote soup --user @alice@example.com +python -m prami.cli event-complete +python -m prami.cli admin status +``` + +When an action is favourite/boost-worthy or hits a milestone, the CLI notes it under the reply. + +## Interactive shell + +```bash +python -m prami.cli shell +``` + +Then type interactions as if they were mentions: + +``` +@alice feed +@bob play +@alice status +@charlie pet +tick 2h +debug +exit +``` + +You can also set a nickname (`@alice call me Soup Wizard`) and inspect a user with +`whois @alice`. + +Inside the shell: `tick`, `tick 30m`, `tick 3h` simulate time; `debug` prints raw state; +`exit` or `quit` leaves. + +## Inspect raw state (dev only) + +```bash +python -m prami.cli debug-state +``` + +Prints the raw numbers — hunger, happiness, energy, health, cleanliness, social, trust, +chaos, asleep, mood and the last updated timestamp. Normal users only ever see the +narrative `status`, never these numbers. + +## Simulate time passing + +```bash +python -m prami.cli tick # one decay step +python -m prami.cli tick --hours 3 +python -m prami.cli tick --minutes 30 +``` + +This applies the exact same decay logic the scheduler runs. One step equals +`STATE_DECAY_INTERVAL_MINUTES` of real time, so `--hours 3` applies as many steps as the +scheduler would over three hours. + +## Reset + +```bash +python -m prami.cli reset # asks for confirmation +python -m prami.cli reset --yes # no prompt +``` + +Resets the pet to defaults and clears interactions, cooldowns, events and processed +statuses. User records are kept. + +## Inside Docker Compose + +The same commands work in the container, against the Compose Postgres: + +```bash +docker compose run --rm prami python -m prami.cli status +docker compose run --rm prami python -m prami.cli feed --user @alice@example.com +docker compose run --rm prami python -m prami.cli shell +docker compose run --rm prami python -m prami.cli tick --hours 4 +docker compose run --rm prami python -m prami.cli debug-state +```
@@ -0,0 +1,133 @@+# Teste local (sem precisar de Mastodon) + +O Prami vem com um CLI local para você brincar com o bicho como engine de jogo e de +personalidade antes de conectá-lo ao Mastodon. O CLI usa o **mesmo** parser, game engine, +cooldowns, banco de dados e gerador de respostas que o bot usa — não é um mock separado. + +Por padrão ele usa o `DATABASE_URL` configurado (SQLite é ótimo localmente): + +```bash +export DATABASE_URL="sqlite:///prami.db" # PowerShell no Windows: $env:DATABASE_URL="sqlite:///prami.db" +``` + +> Os comandos do Prami são em inglês (é o que o parser entende). Esta documentação em +> português explica como usá-los. + +## Comandos avulsos + +```bash +python -m prami.cli status +python -m prami.cli feed --user @alice@example.com +python -m prami.cli play --user @bob@example.com +python -m prami.cli pet --user @alice@example.com +python -m prami.cli clean --user @alice@example.com +python -m prami.cli sleep --user @alice@example.com +python -m prami.cli wake --user @alice@example.com +python -m prami.cli help +``` + +O `--user` é opcional; sem ele, um usuário local padrão é usado. Cada usuário tem seus +próprios cooldowns, como contas de verdade. + +## Apelidos e relacionamento + +O Prami guarda dados leves e só de jogo sobre cada usuário (contagem de interações, um +trust score por usuário e um nível de vínculo: stranger → familiar → trusted → friend → +beloved menace). Usuários gentis e frequentes criam vínculo mais rápido; ajudar num estado +crítico vale confiança extra. + +```bash +python -m prami.cli nickname Soup Wizard --user @alice@example.com # define apelido +python -m prami.cli nickname clear --user @alice@example.com # remove o apelido +python -m prami.cli relationship --user @alice@example.com # inspeciona (só dev) +``` + +Apelidos são sanitizados (máx. 32 caracteres, sem URLs, menções, HTML ou quebras de linha) +e têm um cooldown curto. O Prami usa o apelido de vez em quando nas respostas, um pouco mais +para usuários de vínculo alto. Os números crus de relacionamento só aparecem no +`relationship`, nunca nas respostas normais. + +## Recursos sociais (teach, eventos, admin) + +Precisam das flags ligadas (`ENABLE_TEACH_COMMAND`, `ENABLE_EVENTS`). O CLI te concede admin +local. Referência completa: [LOCAL_TESTING.md](LOCAL_TESTING.md). + +```bash +python -m prami.cli teach '"Prami believes soup is weather."' --user @alice@example.com +python -m prami.cli admin memories +python -m prami.cli admin approve-memory 1 +python -m prami.cli event-start snack_vote +python -m prami.cli vote soup --user @alice@example.com +python -m prami.cli event-complete +python -m prami.cli admin status +``` + +Quando uma ação é favourite/boost-worthy ou bate um milestone, o CLI mostra isso abaixo da resposta. + +## Shell interativo + +```bash +python -m prami.cli shell +``` + +Aí é só digitar as interações como se fossem menções: + +``` +@alice feed +@bob play +@alice status +@charlie pet +tick 2h +debug +exit +``` + +Você também pode definir um apelido (`@alice call me Soup Wizard`) e inspecionar um usuário +com `whois @alice`. + +Dentro do shell: `tick`, `tick 30m`, `tick 3h` simulam o tempo; `debug` mostra o estado +bruto; `exit` ou `quit` sai. + +## Inspecionar o estado bruto (só para dev) + +```bash +python -m prami.cli debug-state +``` + +Mostra os números crus — fome, felicidade, energia, saúde, limpeza, sociabilidade, +confiança, caos, dormindo, humor e o timestamp da última atualização. Usuários normais só +veem o `status` narrativo, nunca esses números. + +## Simular a passagem do tempo + +```bash +python -m prami.cli tick # um passo de decay +python -m prami.cli tick --hours 3 +python -m prami.cli tick --minutes 30 +``` + +Aplica exatamente a mesma lógica de decay que o scheduler roda. Um passo equivale a +`STATE_DECAY_INTERVAL_MINUTES` de tempo real, então `--hours 3` aplica a mesma quantidade +de passos que o scheduler aplicaria em três horas. + +## Reset + +```bash +python -m prami.cli reset # pede confirmação +python -m prami.cli reset --yes # sem prompt +``` + +Volta o bicho aos valores padrão e limpa interações, cooldowns, eventos e status +processados. Os registros de usuários são mantidos. + +## Dentro do Docker Compose + +Os mesmos comandos funcionam no contêiner, contra o Postgres do Compose: + +```bash +docker compose run --rm prami python -m prami.cli status +docker compose run --rm prami python -m prami.cli feed --user @alice@example.com +docker compose run --rm prami python -m prami.cli shell +docker compose run --rm prami python -m prami.cli tick --hours 4 +docker compose run --rm prami python -m prami.cli debug-state +```
@@ -0,0 +1,47 @@+# Prami's personality + +Prami is meant to read like a tiny social creature, not a chatbot: cute, strange, +affectionate, dramatic, a little suspicious, occasionally chaotic, never cruel. Replies are +short (1–4 sentences); only `status` runs a touch longer. + +## How a reply is chosen + +Every reply is a handcrafted template — no generative AI. For each command there are named +template groups, and Prami's current state decides which group to draw from, then picks a +line at random for variety. + +| Command | Groups | +|---|---| +| status | normal, hungry, tired, happy, sad, chaotic, dirty, asleep | +| feed | normal, very_hungry, already_full, suspicious, happy, chaotic | +| play | normal, energetic, tired, chaotic, asleep | +| pet | normal, affectionate, low_trust, high_trust, annoyed, asleep | +| clean | normal, dirty, already_clean, angry, chaotic | +| sleep | normal, already_asleep, too_energetic, grateful | +| wake | normal, already_awake, too_tired, annoyed | + +Action replies are chosen from the state **before** the command is applied, so feeding a +starving Prami sounds desperate, and feeding a full one sounds unimpressed. + +Autonomous posts have their own groups: hungry, lonely, sleepy, chaotic, happy, dirty, +existential, community_appreciation, weird_observation. The current state picks the base +group; sometimes Prami drifts into an existential or weird-observation post instead. + +## Personality traits + +`PET_PERSONALITY` is free text. Recognised trait words tune the thresholds that pick +groups: + +- **chaotic** / **calm** — how easily Prami tips into weirder, chaotic lines +- **suspicious** / **affectionate** / **clingy** — how guarded the care responses are +- **dramatic** — how readily it gets sleepy and theatrical +- **affectionate** — a lower bar for happy/affectionate responses + +So `PET_PERSONALITY=chaotic, weird` feels noticeably more feral than +`PET_PERSONALITY=calm, shy`, with the same code and templates. + +## Personality packs (future) + +Templates live in a pack registry keyed by name (`default` ships today). `PET_PERSONALITY_PACK` +selects which pack is active. Adding a new pack later is just registering another set of +template groups — the selection logic stays the same.
@@ -0,0 +1,50 @@+# A personalidade do Prami + +O Prami foi feito para soar como um bichinho social, não como um chatbot: fofo, estranho, +afetuoso, dramático, um pouco desconfiado, ocasionalmente caótico, nunca cruel. As respostas +são curtas (1 a 4 frases); só o `status` é um pouco mais longo. + +> Os textos do Prami são sempre em inglês. Esta documentação em português explica como o +> sistema funciona. + +## Como uma resposta é escolhida + +Toda resposta é um template feito à mão — sem IA generativa. Cada comando tem grupos de +templates nomeados, e o estado atual do Prami decide de qual grupo puxar; depois sorteia +uma frase do grupo para dar variedade. + +| Comando | Grupos | +|---|---| +| status | normal, hungry, tired, happy, sad, chaotic, dirty, asleep | +| feed | normal, very_hungry, already_full, suspicious, happy, chaotic | +| play | normal, energetic, tired, chaotic, asleep | +| pet | normal, affectionate, low_trust, high_trust, annoyed, asleep | +| clean | normal, dirty, already_clean, angry, chaotic | +| sleep | normal, already_asleep, too_energetic, grateful | +| wake | normal, already_awake, too_tired, annoyed | + +As falas de ação usam o estado **antes** de o comando ser aplicado, então alimentar um +Prami faminto soa desesperado, e alimentar um já cheio soa entediado. + +Os posts autônomos têm grupos próprios: hungry, lonely, sleepy, chaotic, happy, dirty, +existential, community_appreciation, weird_observation. O estado atual escolhe o grupo base; +às vezes o Prami escapa para um post existencial ou de observação estranha. + +## Traços de personalidade + +`PET_PERSONALITY` é texto livre. Palavras de traço reconhecidas ajustam os limiares que +escolhem os grupos: + +- **chaotic** / **calm** — com que facilidade o Prami pende para falas mais esquisitas/caóticas +- **suspicious** / **affectionate** / **clingy** — quão desconfiadas são as respostas de carinho +- **dramatic** — com que facilidade fica sonolento e teatral +- **affectionate** — barra mais baixa para respostas felizes/afetuosas + +Então `PET_PERSONALITY=chaotic, weird` soa bem mais feral que `PET_PERSONALITY=calm, shy`, +com o mesmo código e os mesmos templates. + +## Personality packs (futuro) + +Os templates ficam num registro de packs por nome (`default` é o que existe hoje). +`PET_PERSONALITY_PACK` seleciona o pack ativo. Adicionar um novo pack depois é só registrar +outro conjunto de grupos de templates — a lógica de seleção continua a mesma.
@@ -0,0 +1,65 @@+# Using Prami + +Prami is a shared community pet. There is one creature, and everyone looks after it +together by mentioning its Mastodon account. + +## Commands + +Mention the bot and include one of these words (case does not matter): + +| Command | What it does | +|---|---| +| `status` | See how Prami is doing right now | +| `feed` | Give Prami food | +| `play` | Play with Prami | +| `pet` | A little affection | +| `clean` | Clean Prami (it will resent this) | +| `sleep` | Put Prami to bed | +| `wake` | Wake Prami up | +| `help` | List the commands | + +Example: + +``` +@prami feed +@prami status +``` + +A few aliases also work: `food`, `eat`, `pat`, `cuddle`, `bath`, `wash`, `nap`, +`wakeup`, `?`. + +## What Prami tracks + +Hunger, happiness, energy, health, cleanliness, social, trust, and chaos — plus whether +it is asleep, its age in days, and a mood derived from all of the above. These values +drift on their own over time: Prami gets hungry, tired, dirty and lonely, so the community +has to keep up. If hunger, cleanliness or energy stay bad for too long, its health starts +to drop. + +## Cooldowns + +So that one person — or a busy timeline — can't run the pet ragged, each command has a +limit: + +| Command | Per user | Globally | +|---|---|---| +| feed | once / 30 min | 10 / hour | +| play | once / 20 min | 15 / hour | +| pet | once / 10 min | 30 / hour | +| clean | once / 60 min | 5 / hour | +| sleep | — | once / 120 min | +| wake | — | once / 60 min | + +There is also an overall cap per user per hour and a global cap per hour. + +## Sleep + +While Prami is asleep, `feed`, `play` and `clean` are gently declined until someone uses +`wake`. Waking it while its energy is very low will get you a complaint, but it will +still wake up. + +## Replies and autonomous posts + +Replies are unlisted by default so the public timeline stays clean. Prami also posts on +its own from time to time, based on its current mood — also unlisted by default. Both +visibilities are configurable.
@@ -0,0 +1,68 @@+# Usando o Prami + +O Prami é um bicho de estimação comunitário e compartilhado. Existe uma única criatura, e +todo mundo cuida dela em conjunto mencionando a conta dele no Mastodon. + +> Os comandos são sempre em inglês — o Prami só entende essas palavras. Esta documentação +> em português explica como usá-los. + +## Comandos + +Mencione o bot e inclua uma destas palavras (maiúsculas/minúsculas não importam): + +| Comando | O que faz | +|---|---| +| `status` | Mostra como o Prami está agora | +| `feed` | Dá comida ao Prami | +| `play` | Brinca com o Prami | +| `pet` | Um carinho rápido | +| `clean` | Dá banho no Prami (ele vai ficar magoado) | +| `sleep` | Coloca o Prami para dormir | +| `wake` | Acorda o Prami | +| `help` | Lista os comandos | + +Exemplo: + +``` +@prami feed +@prami status +``` + +Alguns apelidos também funcionam: `food`, `eat`, `pat`, `cuddle`, `bath`, `wash`, `nap`, +`wakeup`, `?`. + +## O que o Prami acompanha + +Fome, felicidade, energia, saúde, limpeza, sociabilidade, confiança e caos — além de estar +dormindo ou não, a idade em dias e um humor derivado de tudo isso. Esses valores mudam +sozinhos com o tempo: o Prami fica com fome, cansado, sujo e carente, então a comunidade +precisa acompanhar. Se fome, limpeza ou energia ficarem ruins por tempo demais, a saúde +dele começa a cair. + +## Tempos de espera (cooldowns) + +Para que uma pessoa só — ou uma timeline movimentada — não esgote o bichinho, cada comando +tem um limite: + +| Comando | Por usuário | No geral | +|---|---|---| +| feed | 1x / 30 min | 10 / hora | +| play | 1x / 20 min | 15 / hora | +| pet | 1x / 10 min | 30 / hora | +| clean | 1x / 60 min | 5 / hora | +| sleep | — | 1x / 120 min | +| wake | — | 1x / 60 min | + +Há também um limite total por usuário por hora e um limite global por hora. + +## Sono + +Enquanto o Prami está dormindo, `feed`, `play` e `clean` são recusados com gentileza até +alguém usar `wake`. Acordá-lo com a energia muito baixa rende uma reclamação, mas ele +acorda mesmo assim. + +## Respostas e posts autônomos + +As respostas são `unlisted` (não listadas) por padrão, para não poluir a timeline pública. +O Prami também posta sozinho de vez em quando, conforme o humor do momento — também +`unlisted` por padrão. As duas visibilidades são configuráveis.
@@ -0,0 +1,1 @@+__version__ = "0.1.0"
@@ -0,0 +1,33 @@+import logging + +import prami +from .app import Poller, bootstrap +from .config import config +from .scheduler import build_scheduler + + +def main(): + logging.basicConfig( + level=config.log_level.upper(), + format="%(asctime)s %(levelname)-7s %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + logging.getLogger("apscheduler").setLevel(logging.WARNING) + + log = logging.getLogger("prami") + log.info("Starting Prami %s", prami.__version__) + + client = bootstrap() + scheduler = build_scheduler(client) + scheduler.start() + + try: + Poller(client).run() + except KeyboardInterrupt: + log.info("Shutting down, see you soon") + finally: + scheduler.shutdown(wait=False) + + +if __name__ == "__main__": + main()
@@ -0,0 +1,3 @@+from .registry import REGISTRY, ActionRegistry, build_registry + +__all__ = ["REGISTRY", "ActionRegistry", "build_registry"]
@@ -0,0 +1,10 @@+from .. import admin as admin_mod +from .base import Action + + +class AdminAction(Action): + name = "admin" + admin_only = True + + def run(self, ctx): + return admin_mod.run(ctx)
@@ -0,0 +1,48 @@+from dataclasses import dataclass + + +@dataclass +class ActionContext: + session: object + pet: object + user: object + arg: str + config: object + settings: object + now: object + status_id: object = None + + +@dataclass +class Outcome: + text: str + accepted: bool = True + record: bool = True + address: bool = True + favourite: bool = False + boost: bool = False + was_critical: bool = False + visibility: str = None + + +class Action: + name = "" + aliases = () + phrase_aliases = () + admin_only = False + runs_while_asleep = True + user_cooldown_minutes = 0 + global_cooldown_minutes = 0 + per_hour_limit = None + + def enabled(self, config): + return True + + def on_disabled(self, ctx): + return Outcome(f"{ctx.pet.name} can't do that right now — it's turned off.", accepted=False, record=False) + + def precheck(self, ctx): + return None + + def run(self, ctx): + raise NotImplementedError
@@ -0,0 +1,125 @@+from .. import relationships, responses, social +from ..state import clamp, compute_mood +from .base import Action, Outcome + + +def _snapshot(pet): + return { + "hunger": pet.hunger, + "social": pet.social, + "cleanliness": pet.cleanliness, + "health": pet.health, + "energy": pet.energy, + "chaos": pet.chaos, + } + + +class CareAction(Action): + def effect(self, pet): + raise NotImplementedError + + def run(self, ctx): + pet = ctx.pet + text = responses.for_command(self.name, pet) + before = _snapshot(pet) + before_critical = relationships.is_critical(pet) + + self.effect(pet) + pet.mood = compute_mood(pet) + + return Outcome( + text=text, + favourite=social.favourite_worthy(self.name, before, ctx.user), + boost=social.boost_worthy(before_critical, relationships.is_critical(pet)), + was_critical=before_critical, + ) + + +class FeedAction(CareAction): + name = "feed" + aliases = ("food", "eat", "snack", "feast") + runs_while_asleep = False + user_cooldown_minutes = 30 + per_hour_limit = 10 + + def effect(self, pet): + was_hungry = pet.hunger > 60 + pet.hunger = clamp(pet.hunger - 35) + pet.happiness = clamp(pet.happiness + 5) + if was_hungry: + pet.health = clamp(pet.health + 3) + if pet.hunger < 10: + pet.chaos = clamp(pet.chaos + 8) + pet.happiness = clamp(pet.happiness - 5) + + +class PlayAction(CareAction): + name = "play" + aliases = ("fun", "games", "toy") + runs_while_asleep = False + user_cooldown_minutes = 20 + per_hour_limit = 15 + + def effect(self, pet): + pet.happiness = clamp(pet.happiness + 12) + pet.social = clamp(pet.social + 10) + pet.energy = clamp(pet.energy - 15) + pet.chaos = clamp(pet.chaos + 5) + + +class PetAction(CareAction): + name = "pet" + aliases = ("pat", "pats", "cuddle", "scritch", "scratch", "boop") + user_cooldown_minutes = 10 + per_hour_limit = 30 + + def effect(self, pet): + pet.trust = clamp(pet.trust + 6) + pet.social = clamp(pet.social + 5) + pet.happiness = clamp(pet.happiness + 6) + pet.energy = clamp(pet.energy - 2) + + +class CleanAction(CareAction): + name = "clean" + aliases = ("bath", "wash", "scrub", "tidy") + runs_while_asleep = False + user_cooldown_minutes = 60 + per_hour_limit = 5 + + def effect(self, pet): + pet.cleanliness = clamp(pet.cleanliness + 40) + pet.happiness = clamp(pet.happiness - 6) + pet.chaos = clamp(pet.chaos + 3) + + +class SleepAction(CareAction): + name = "sleep" + aliases = ("nap", "bed", "rest") + global_cooldown_minutes = 120 + + def precheck(self, ctx): + if ctx.pet.asleep: + return Outcome(responses.already_asleep(ctx.pet), accepted=False, record=False) + return None + + def effect(self, pet): + pet.asleep = True + pet.happiness = clamp(pet.happiness + 2) + + +class WakeAction(CareAction): + name = "wake" + aliases = ("wakeup", "awake", "rise") + global_cooldown_minutes = 60 + + def precheck(self, ctx): + if not ctx.pet.asleep: + return Outcome(responses.already_awake(ctx.pet), accepted=False, record=False) + return None + + def effect(self, pet): + pet.asleep = False + if pet.energy < 20: + pet.happiness = clamp(pet.happiness - 3) + pet.chaos = clamp(pet.chaos + 4)
@@ -0,0 +1,40 @@+from .. import events as events_mod +from .. import responses +from .base import Action, Outcome + + +class EventAction(Action): + name = "event" + + def run(self, ctx): + event = events_mod.active(ctx.session, ctx.now) + if event is None: + return Outcome(responses.no_event(ctx.pet), record=False) + text = responses.event_show(ctx.pet, event.title, event.description, events_mod.options_text(event)) + return Outcome(text, record=False) + + +class EventHelpAction(Action): + name = "helpevent" + aliases = ("help-event",) + + def run(self, ctx): + event = events_mod.active(ctx.session, ctx.now) + if event is None: + return Outcome(responses.no_event(ctx.pet), record=False) + text = responses.event_help(ctx.pet, event.title, events_mod.options_text(event)) + return Outcome(text, record=False) + + +class VoteAction(Action): + name = "vote" + + def run(self, ctx): + event = events_mod.active(ctx.session, ctx.now) + if event is None: + return Outcome(responses.no_event(ctx.pet), accepted=False, record=False) + ok, reason = events_mod.vote(ctx.session, event, ctx.user, ctx.arg) + if not ok: + text = responses.vote_rejected(ctx.pet, reason, events_mod.options_text(event)) + return Outcome(text, accepted=False, record=False) + return Outcome(responses.vote_ack(ctx.pet, event.title))
@@ -0,0 +1,10 @@+from .. import responses +from .base import Action, Outcome + + +class HelpAction(Action): + name = "help" + aliases = ("commands", "menu", "?") + + def run(self, ctx): + return Outcome(responses.help_text(ctx.config))
@@ -0,0 +1,22 @@+from .. import responses, sanitize +from .base import Action, Outcome + + +class NicknameAction(Action): + name = "nickname" + aliases = ("nick",) + phrase_aliases = ("name me", "call me") + user_cooldown_minutes = 5 + + def run(self, ctx): + arg = ctx.arg.strip() + if arg.lower() == "clear": + ctx.user.nickname = None + return Outcome(responses.nickname_cleared(ctx.pet), address=False) + + cleaned = sanitize.clean_nickname(arg) + if not cleaned: + return Outcome(responses.nickname_rejected(ctx.pet), accepted=False, record=False, address=False) + + ctx.user.nickname = cleaned + return Outcome(responses.nickname_set(ctx.pet, cleaned), address=False)
@@ -0,0 +1,57 @@+class ActionRegistry: + def __init__(self): + self._by_name = {} + self._by_alias = {} + self._phrases = {} + + def register(self, action): + self._by_name[action.name] = action + for alias in (action.name, *action.aliases): + self._by_alias[alias.lower()] = action.name + for phrase in action.phrase_aliases: + self._phrases[phrase.lower()] = action.name + + def resolve(self, command): + return self._by_name.get(command) or self._by_name["unknown"] + + def canonical(self, token): + return self._by_alias.get((token or "").lower()) + + def alias_map(self): + return dict(self._by_alias) + + def phrase_map(self): + return dict(self._phrases) + + def is_enabled(self, command, config): + action = self._by_name.get(command) + return bool(action and action.enabled(config)) + + def names(self): + return list(self._by_name) + + +def build_registry(): + from .admin import AdminAction + from .care import CleanAction, FeedAction, PetAction, PlayAction, SleepAction, WakeAction + from .events import EventAction, EventHelpAction, VoteAction + from .help import HelpAction + from .nickname import NicknameAction + from .status import StatusAction + from .teach import TeachAction + from .unknown import UnknownAction + + registry = ActionRegistry() + actions = ( + StatusAction, HelpAction, UnknownAction, + FeedAction, PlayAction, PetAction, CleanAction, SleepAction, WakeAction, + NicknameAction, TeachAction, + EventAction, EventHelpAction, VoteAction, + AdminAction, + ) + for action_cls in actions: + registry.register(action_cls()) + return registry + + +REGISTRY = build_registry()
@@ -0,0 +1,10 @@+from .. import responses +from .base import Action, Outcome + + +class StatusAction(Action): + name = "status" + aliases = ("stat", "stats", "how", "howareyou", "check") + + def run(self, ctx): + return Outcome(responses.status(ctx.pet))
@@ -0,0 +1,20 @@+from .. import memory, responses +from .base import Action, Outcome + + +class TeachAction(Action): + name = "teach" + aliases = ("remember", "learn") + user_cooldown_minutes = 10 + + def enabled(self, config): + return config.enable_teach_command + + def on_disabled(self, ctx): + return Outcome(responses.teach_disabled(ctx.pet), accepted=False, record=False) + + def run(self, ctx): + suggestion = memory.submit(ctx.session, ctx.pet, ctx.user, ctx.arg, ctx.config) + if suggestion is None: + return Outcome(responses.teach_rejected(ctx.pet), accepted=False, record=False) + return Outcome(responses.teach_ack(ctx.pet))
@@ -0,0 +1,9 @@+from .. import responses +from .base import Action, Outcome + + +class UnknownAction(Action): + name = "unknown" + + def run(self, ctx): + return Outcome(responses.unknown(ctx.pet))
@@ -0,0 +1,135 @@+from . import memory +from . import repository as repo +from . import stats +from .actions.base import Outcome + +VISIBILITIES = ("public", "unlisted", "private", "direct") +ADMIN_VISIBILITY = "direct" + + +def run(ctx): + config, user = ctx.config, ctx.user + if not config.is_admin(user.acct): + if config.admin_refuse_nonadmin: + return Outcome( + "That's an admin-only command.", + accepted=False, record=False, address=False, visibility=ADMIN_VISIBILITY, + ) + return None + + parts = ctx.arg.split() + sub = parts[0].lower() if parts else "help" + rest = " ".join(parts[1:]).strip() + + if sub == "stats": + text = stats.text_report(ctx.session, ctx.config, ctx.settings, ctx.now) + else: + text = _dispatch(ctx.session, ctx.pet, ctx.settings, sub, rest) + repo.log_event(ctx.session, ctx.pet.id, "admin", f"{user.acct}: {ctx.arg}".strip()) + return Outcome(text, record=False, address=False, visibility=ADMIN_VISIBILITY) + + +def _dispatch(session, pet, settings, sub, rest): + if sub == "status": + return _status(session, settings) + if sub == "pause": + settings.paused = True + return "Prami is paused — it will stop reacting until you resume it." + if sub == "resume": + settings.paused = False + return "Prami is back. It missed you, probably." + if sub == "set-visibility": + if rest in VISIBILITIES: + settings.default_visibility = rest + return f"Default reply visibility set to {rest}." + return f"Visibility must be one of: {', '.join(VISIBILITIES)}." + if sub == "set-autopost": + settings.autopost_enabled = _on(rest) + return f"Autoposts {_state(settings.autopost_enabled)}." + if sub == "set-favourites": + settings.favourites_enabled = _on(rest) + return f"Favourites {_state(settings.favourites_enabled)}." + if sub == "set-boosts": + settings.boosts_enabled = _on(rest) + return f"Boosts {_state(settings.boosts_enabled)}." + if sub == "blocked-users": + return _blocked(session) + if sub == "block" and rest: + repo.block_user(session, rest) + return f"Blocked {rest.lstrip('@')}." + if sub == "unblock" and rest: + target = repo.get_user(session, rest) + if target: + target.blocked = False + return f"Unblocked {rest.lstrip('@')}." + if sub == "memories" or (sub == "memory-list" and rest in ("", "pending")): + return _memory_list(session, "pending") + if sub == "memory-list": + return _memory_list(session, rest) + if sub == "approve-memory" and rest: + return _review(session, rest, approve=True) + if sub == "reject-memory" and rest: + return _review(session, rest, approve=False) + return _help() + + +def _on(value): + return value.strip().lower() in ("on", "true", "yes", "1", "enable", "enabled") + + +def _state(enabled): + return "enabled" if enabled else "disabled" + + +def _status(session, settings): + pending = len(memory.by_status(session, "pending")) + approved = len(memory.by_status(session, "approved")) + return ( + f"paused={settings.paused} · autopost={_state(settings.autopost_enabled)} · " + f"favourites={_state(settings.favourites_enabled)} · boosts={_state(settings.boosts_enabled)} · " + f"visibility={settings.default_visibility} · memories: {pending} pending, {approved} approved" + ) + + +def _blocked(session): + from .models import User + from sqlalchemy import select + + blocked = list(session.scalars(select(User).where(User.blocked.is_(True)))) + if not blocked: + return "No blocked users." + return "Blocked: " + ", ".join(u.acct for u in blocked) + + +def _memory_list(session, status): + status = status if status in ("pending", "approved", "rejected") else "pending" + items = memory.by_status(session, status) + if not items: + return f"No {status} memories." + lines = [f"#{m.id} [{m.category}] {m.content}" for m in items[:10]] + return f"{status.capitalize()} memories:\n" + "\n".join(lines) + + +def _review(session, rest, approve): + parts = rest.split(None, 1) + try: + suggestion_id = int(parts[0]) + except ValueError: + return "Usage: approve-memory <id> | reject-memory <id> [reason]" + + if approve: + result = memory.approve(session, suggestion_id, "admin") + return f"Approved memory #{suggestion_id}." if result else f"No pending memory #{suggestion_id}." + + reason = parts[1] if len(parts) > 1 else "" + result = memory.reject(session, suggestion_id, "admin", reason) + return f"Rejected memory #{suggestion_id}." if result else f"No pending memory #{suggestion_id}." + + +def _help(): + return ( + "Admin commands: status · stats · pause · resume · set-visibility <v> · " + "set-autopost on|off · set-favourites on|off · set-boosts on|off · " + "blocked-users · block <user> · unblock <user> · memories · " + "memory-list pending|approved · approve-memory <id> · reject-memory <id> [reason]" + )
@@ -0,0 +1,135 @@+import logging +import time + +from . import engine +from . import repository as repo +from . import safety +from .config import config +from .db import create_all, init_engine, session_scope, wait_for_db +from .mastodon_client import PramiClient +from .parser import parse + +log = logging.getLogger("prami.app") + + +def bootstrap(): + init_engine(config.database_url) + wait_for_db() + create_all() + + with session_scope() as session: + pet = repo.get_or_create_pet(session, config) + for acct in config.blocked_users: + repo.block_user(session, acct) + repo.log_event(session, pet.id, "startup", "prami started") + log.info("Pet ready: %s the %s (day %s, mood %s)", pet.name, pet.species, pet.age_days, pet.mood) + + return PramiClient(config) + + +class Poller: + def __init__(self, client): + self.client = client + self.last_id = None + self.bot_acct = (config.bot_acct or client.me.acct).lstrip("@") + self.bot_id = client.me.id + + def poll_once(self): + mentions = self.client.fetch_mentions(since_id=self.last_id) + for notif in reversed(mentions): + if self.last_id is None or notif.id > self.last_id: + self.last_id = notif.id + self._handle(notif) + + def _handle(self, notif): + status = getattr(notif, "status", None) + if status is None: + return + + account = notif.account + if account.id == self.bot_id or account.acct.lstrip("@") == self.bot_acct: + return + + sid = str(status.id) + with session_scope() as session: + if repo.is_status_processed(session, sid): + return + repo.mark_status_processed(session, sid) + + user = repo.get_or_create_user( + session, + acct=account.acct, + mastodon_id=str(account.id), + display_name=account.display_name or "", + ) + pet = repo.get_pet(session) + parsed = parse(status.content) + command = parsed.name + result = engine.handle( + session, pet, user, command, config, status_id=sid, arg=parsed.arg + ) + + if result is None: + return + + try: + self.client.reply(status, f"@{account.acct} {result.text}", result.visibility) + log.info("Replied to @%s (%s, accepted=%s)", account.acct, command, result.accepted) + except Exception: + log.exception("Failed to reply to @%s", account.acct) + + if result.boost: + self._react(status, account, "boost") + elif result.favourite: + self._react(status, account, "favourite") + + if result.milestones: + self._announce_milestones(result.milestones, status, account) + + def _announce_milestones(self, achieved, status, account): + for milestone in achieved: + if config.enable_milestone_posts: + try: + self.client.post(milestone["text"], config.milestone_post_visibility) + except Exception: + log.exception("Failed to post milestone %s", milestone["key"]) + if milestone.get("boost"): + self._react(status, account, "boost") + elif milestone.get("favourite"): + self._react(status, account, "favourite") + + def _react(self, status, account, action): + sid = str(status.id) + visibility = getattr(status, "visibility", "public") + with session_scope() as session: + settings = repo.get_settings(session, config) + user = repo.get_user(session, account.acct) + check = safety.may_boost if action == "boost" else safety.may_favourite + allowed, reason = check(session, settings, config, user, visibility, sid) + if not allowed: + log.info("Skipped %s of %s: %s", action, sid, reason) + return + + try: + (self.client.boost if action == "boost" else self.client.favourite)(status) + except Exception: + log.exception("Failed to %s %s", action, sid) + return + + with session_scope() as session: + if not repo.has_social_action(session, sid, action): + pet = repo.get_pet(session) + user = repo.get_user(session, account.acct) + uid = user.id if user else None + repo.record_social_action(session, pet.id, uid, sid, action, "worthy interaction") + repo.log_event(session, pet.id, action, f"{action} of {sid} by @{account.acct}") + log.info("%s %s by @%s", action.capitalize(), sid, account.acct) + + def run(self): + log.info("Listening for mentions every %ss", config.poll_interval) + while True: + try: + self.poll_once() + except Exception: + log.exception("Polling cycle failed") + time.sleep(config.poll_interval)
@@ -0,0 +1,45 @@+import random + +from . import events +from . import memory +from . import responses +from . import templates +from .models import utcnow + +FLAVOR = ("weird_observation", "existential", "community_appreciation") + + +def choose_category(session, pet, config): + options = [responses.autopost_group(pet), *FLAVOR] + + if config.enable_events and events.active(session) is not None: + options += ["active_event_reminder", "active_event_reminder"] + if memory.by_status(session, "approved"): + options.append("approved_memory_callback") + + fresh = [c for c in options if c != pet.last_autopost_category] + return random.choice(fresh or options) + + +def render(session, pet, category): + if category == "active_event_reminder": + event = events.active(session) + if event is not None: + return templates.AUTOPOST_EVENT_REMINDER.format( + name=pet.name, title=event.title, options=events.options_text(event) + ) + if category == "approved_memory_callback": + suggestion = memory.random_approved(session) + if suggestion is not None: + return templates.AUTOPOST_MEMORY_CALLBACK.format(name=pet.name, memory=suggestion.content) + + pool = responses.AUTOPOST.get(category) or responses.AUTOPOST["weird_observation"] + return responses._render(pool, pet) + + +def compose(session, pet, config): + category = choose_category(session, pet, config) + text = render(session, pet, category) + pet.last_autopost_category = category + pet.last_autopost_at = utcnow() + return text
@@ -0,0 +1,317 @@+import argparse +import sys + +from . import decay +from . import engine +from . import repository as repo +from . import stats +from .config import config +from .db import create_all, init_engine, session_scope, wait_for_db +from .models import utcnow +from .parser import parse + +DEFAULT_USER = "local@cli" +ACTION_COMMANDS = ["status", "feed", "play", "pet", "clean", "sleep", "wake", "help"] + + +def _open(): + init_engine(config.database_url) + wait_for_db() + create_all() + with session_scope() as session: + repo.get_or_create_pet(session, config) + + +def run_command(command, user_acct, arg=""): + user_acct = (user_acct or DEFAULT_USER).lstrip("@") + with session_scope() as session: + pet = repo.get_pet(session) + user = repo.get_or_create_user(session, user_acct) + result = engine.handle(session, pet, user, command, config, arg=arg) + if result is None: + return {"text": f"({user_acct} is blocked or Prami is paused — nothing happens.)"} + return { + "text": result.text, + "favourite": result.favourite, + "boost": result.boost, + "milestones": [m["text"] for m in result.milestones], + } + + +def _format_result(result): + line = result["text"] + notes = [] + if result.get("boost"): + notes.append("boost-worthy") + elif result.get("favourite"): + notes.append("favourite-worthy") + if notes: + line += f"\n · ({', '.join(notes)})" + for text in result.get("milestones", []): + line += f"\n * milestone: {text}" + return line + + +def render_relationship(user): + if user is None: + return "No relationship on record for that user yet." + rows = [ + ("acct", user.acct), + ("nickname", user.nickname or "—"), + ("bond_level", user.bond_level), + ("trust_score", user.trust_score), + ("total_interactions", user.total_interactions), + ("feed/play/pet", f"{user.feed_count}/{user.play_count}/{user.pet_count}"), + ("clean/sleep/wake", f"{user.clean_count}/{user.sleep_count}/{user.wake_count}"), + ("first_seen", f"{user.first_seen:%Y-%m-%d %H:%M} UTC"), + ("last_seen", f"{user.last_seen:%Y-%m-%d %H:%M} UTC"), + ] + width = max(len(label) for label, _ in rows) + return "\n".join(f"{label.ljust(width)} : {value}" for label, value in rows) + + +def simulate_ticks(minutes): + ticks = 1 if minutes <= 0 else max(1, minutes // config.decay_interval) + with session_scope() as session: + pet = repo.get_pet(session) + for _ in range(ticks): + decay.tick(pet) + mood = pet.mood + return ticks, mood + + +def render_debug(pet): + if pet is None: + return "No pet found." + rows = [ + ("name", pet.name), + ("hunger", pet.hunger), + ("happiness", pet.happiness), + ("energy", pet.energy), + ("health", pet.health), + ("cleanliness", pet.cleanliness), + ("social", pet.social), + ("trust", pet.trust), + ("chaos", pet.chaos), + ("asleep", pet.asleep), + ("mood", pet.mood), + ("age_days", pet.age_days), + ("updated_at", f"{pet.updated_at:%Y-%m-%d %H:%M:%S} UTC"), + ] + width = max(len(label) for label, _ in rows) + return "\n".join(f"{label.ljust(width)} : {value}" for label, value in rows) + + +def cmd_action(args): + print(_format_result(run_command(args.command, args.user))) + + +def cmd_nickname(args): + print(run_command("nickname", args.user, " ".join(args.name))["text"]) + + +def cmd_relationship(args): + acct = (args.user or DEFAULT_USER).lstrip("@") + with session_scope() as session: + print(render_relationship(repo.get_user(session, acct))) + + +def cmd_teach(args): + print(run_command("teach", args.user, " ".join(args.content))["text"]) + + +def cmd_event(args): + print(run_command("event", args.user)["text"]) + + +def cmd_vote(args): + print(_format_result(run_command("vote", args.user, args.option))) + + +def cmd_event_start(args): + from . import events + + with session_scope() as session: + pet = repo.get_pet(session) + template = next((t for t in events.TEMPLATES if t["key"] == args.key), None) if args.key else None + event = events.start(session, pet, config, template=template) + print(f"Started event: {event.title} ({events.options_text(event)})") + + +def cmd_event_complete(args): + from . import events, milestones + + with session_scope() as session: + pet = repo.get_pet(session) + event = events.active(session) + if event is None: + print("No active event.") + return + outcome = events.complete(session, event, pet) + milestones.award_event_completed(session, pet) + print(f"Outcome: {outcome}") + + +def cmd_admin(args): + acct = (args.user or DEFAULT_USER).lstrip("@") + if acct.lower() not in [a.lower() for a in config.admin_accounts]: + config.admin_accounts.append(acct) # local CLI trusts the operator + print(run_command("admin", acct, " ".join(args.args))["text"]) + + +def cmd_debug(args): + with session_scope() as session: + print(render_debug(repo.get_pet(session))) + + +def cmd_stats(args): + with session_scope() as session: + settings = repo.get_settings(session, config) + print(stats.text_report(session, config, settings, utcnow())) + + +def cmd_tick(args): + minutes = args.hours * 60 + args.minutes + ticks, mood = simulate_ticks(minutes) + print(f"Simulated {ticks} decay tick(s). Prami is now {mood}.") + + +def cmd_reset(args): + if not args.yes: + answer = input("Reset local pet state to defaults? [y/N] ").strip().lower() + if answer not in ("y", "yes"): + print("Cancelled.") + return + with session_scope() as session: + repo.reset_world(session, config) + print("Prami has been reset to a fresh, slightly confused default state.") + + +def cmd_shell(args): + print("Prami local shell — try '@alice feed', 'status', 'tick 2h', 'debug', or 'exit'.") + while True: + try: + line = input("prami> ").strip() + except (EOFError, KeyboardInterrupt): + print() + break + if not line: + continue + + low = line.lower() + if low in ("exit", "quit"): + break + if low == "debug": + with session_scope() as session: + print(render_debug(repo.get_pet(session))) + continue + if low == "tick" or low.startswith("tick "): + ticks, mood = simulate_ticks(_shell_minutes(line)) + print(f"(simulated {ticks} tick(s); mood: {mood})") + continue + if low.startswith("whois"): + parts = line.split(None, 1) + acct = (parts[1].lstrip("@") if len(parts) > 1 else DEFAULT_USER) + with session_scope() as session: + print(render_relationship(repo.get_user(session, acct))) + continue + + if line.startswith("@"): + head, _, rest = line.partition(" ") + user_acct = head[1:] + parsed = parse(rest) + else: + user_acct = DEFAULT_USER + parsed = parse(line) + + print(f"Prami → {_format_result(run_command(parsed.name, user_acct, parsed.arg))}") + + +def _shell_minutes(line): + parts = line.split() + if len(parts) < 2: + return 0 + token = parts[1].lower() + try: + if token.endswith("h"): + return int(token[:-1]) * 60 + if token.endswith("m"): + return int(token[:-1]) + return int(token) + except ValueError: + return 0 + + +def build_parser(): + parser = argparse.ArgumentParser(prog="prami.cli", description="Local testing CLI for Prami") + sub = parser.add_subparsers(dest="cmd", required=True) + + for name in ACTION_COMMANDS: + action = sub.add_parser(name, help=f"Run the '{name}' command as a user") + action.add_argument("--user", default=None, help="Acting user, e.g. @alice@example.com") + action.set_defaults(func=cmd_action, command=name) + + nickname = sub.add_parser("nickname", help="Set or clear your nickname (use 'clear' to remove)") + nickname.add_argument("name", nargs="*", help="Nickname text, or 'clear'") + nickname.add_argument("--user", default=None, help="Acting user, e.g. @alice@example.com") + nickname.set_defaults(func=cmd_nickname) + + relationship = sub.add_parser("relationship", help="Inspect a user's relationship data (dev only)") + relationship.add_argument("--user", default=None, help="User to inspect") + relationship.set_defaults(func=cmd_relationship) + + teach = sub.add_parser("teach", help="Suggest a memory (needs ENABLE_TEACH_COMMAND=true)") + teach.add_argument("content", nargs="*", help="The lore/fact/preference to suggest") + teach.add_argument("--user", default=None, help="Acting user") + teach.set_defaults(func=cmd_teach) + + admin_cmd = sub.add_parser("admin", help="Run an admin command (CLI grants local admin)") + admin_cmd.add_argument("args", nargs="*", help="e.g. status / pause / memories / approve-memory 1") + admin_cmd.add_argument("--user", default=None, help="Acting user") + admin_cmd.set_defaults(func=cmd_admin) + + event = sub.add_parser("event", help="Show the active community event") + event.add_argument("--user", default=None) + event.set_defaults(func=cmd_event) + + vote = sub.add_parser("vote", help="Vote on the active event") + vote.add_argument("option", help="The option key, e.g. soup") + vote.add_argument("--user", default=None) + vote.set_defaults(func=cmd_vote) + + event_start = sub.add_parser("event-start", help="Force-start an event (dev only)") + event_start.add_argument("key", nargs="?", default=None, help="Optional template key") + event_start.set_defaults(func=cmd_event_start) + + sub.add_parser("event-complete", help="Force-complete the active event (dev only)").set_defaults( + func=cmd_event_complete + ) + + sub.add_parser("shell", help="Interactive local shell").set_defaults(func=cmd_shell) + sub.add_parser("debug-state", help="Print raw internal state (dev only)").set_defaults(func=cmd_debug) + sub.add_parser("stats", help="Show local operational metrics (admin/dev only)").set_defaults(func=cmd_stats) + + tick = sub.add_parser("tick", help="Simulate time passing (state decay)") + tick.add_argument("--hours", type=int, default=0) + tick.add_argument("--minutes", type=int, default=0) + tick.set_defaults(func=cmd_tick) + + reset = sub.add_parser("reset", help="Reset local pet state to defaults") + reset.add_argument("--yes", action="store_true", help="Skip the confirmation prompt") + reset.set_defaults(func=cmd_reset) + + return parser + + +def main(argv=None): + try: + sys.stdout.reconfigure(encoding="utf-8") + except (AttributeError, ValueError): + pass + args = build_parser().parse_args(argv) + _open() + args.func(args) + + +if __name__ == "__main__": + main()
@@ -0,0 +1,93 @@+import os +from dataclasses import dataclass, field + +from dotenv import load_dotenv + +load_dotenv() + + +def _int(name, default): + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + return int(raw) + + +def _bool(name, default): + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _float(name, default): + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + return float(raw) + + +def _csv(name): + raw = os.getenv(name, "") + return [item.strip().lstrip("@") for item in raw.split(",") if item.strip()] + + +@dataclass +class Config: + mastodon_base_url: str = os.getenv("MASTODON_BASE_URL", "") + mastodon_access_token: str = os.getenv("MASTODON_ACCESS_TOKEN", "") + bot_acct: str = os.getenv("BOT_ACCOUNT_ACCT", "").lstrip("@") + database_url: str = os.getenv("DATABASE_URL", "sqlite:///prami.db") + + poll_interval: int = _int("POLL_INTERVAL_SECONDS", 30) + decay_interval: int = _int("STATE_DECAY_INTERVAL_MINUTES", 15) + autopost_interval: int = _int("AUTOPOST_INTERVAL_MINUTES", 240) + + default_visibility: str = os.getenv("DEFAULT_VISIBILITY", "unlisted") + autopost_visibility: str = os.getenv("AUTOPOST_VISIBILITY", "unlisted") + autopost_enabled: bool = _bool("AUTOPOST_ENABLED", True) + + pet_name: str = os.getenv("PET_NAME", "Prami") + pet_species: str = os.getenv("PET_SPECIES", "community critter") + pet_personality: str = os.getenv("PET_PERSONALITY", "weird, affectionate, slightly dramatic") + personality_pack: str = os.getenv("PET_PERSONALITY_PACK", "default") + + max_per_user_hour: int = _int("MAX_COMMANDS_PER_USER_PER_HOUR", 20) + max_global_hour: int = _int("MAX_COMMANDS_GLOBAL_PER_HOUR", 200) + blocked_users: list = field(default_factory=lambda: _csv("BLOCKED_USERS")) + + processed_retention_days: int = _int("PROCESSED_RETENTION_DAYS", 14) + + enable_favourites: bool = _bool("ENABLE_FAVOURITES", False) + max_favourites_per_hour: int = _int("MAX_FAVOURITES_PER_HOUR", 8) + max_favourites_per_user_per_day: int = _int("MAX_FAVOURITES_PER_USER_PER_DAY", 3) + allow_favourite_private: bool = _bool("ALLOW_FAVOURITE_PRIVATE", False) + + enable_boosts: bool = _bool("ENABLE_BOOSTS", False) + max_boosts_per_day: int = _int("MAX_BOOSTS_PER_DAY", 1) + min_hours_between_boosts: int = _int("MIN_HOURS_BETWEEN_BOOSTS", 12) + boost_only_public_statuses: bool = _bool("BOOST_ONLY_PUBLIC_STATUSES", True) + + enable_teach_command: bool = _bool("ENABLE_TEACH_COMMAND", False) + admin_accounts: list = field(default_factory=lambda: _csv("ADMIN_ACCOUNTS")) + admin_refuse_nonadmin: bool = _bool("ADMIN_REFUSE_NONADMIN", False) + memory_allow_urls: bool = _bool("MEMORY_ALLOW_URLS", False) + memory_max_length: int = _int("MEMORY_MAX_LENGTH", 180) + + enable_events: bool = _bool("ENABLE_EVENTS", False) + event_chance_per_autopost: float = _float("EVENT_CHANCE_PER_AUTOPOST", 0.15) + max_active_events: int = _int("MAX_ACTIVE_EVENTS", 1) + event_duration_hours: int = _int("EVENT_DURATION_HOURS", 12) + event_cooldown_hours: int = _int("EVENT_COOLDOWN_HOURS", 24) + + enable_milestone_posts: bool = _bool("ENABLE_MILESTONE_POSTS", True) + milestone_post_visibility: str = os.getenv("MILESTONE_POST_VISIBILITY", "unlisted") + + log_level: str = os.getenv("LOG_LEVEL", "INFO") + + def is_admin(self, acct): + target = (acct or "").lstrip("@").lower() + return any(target == a.lower() for a in self.admin_accounts) + + +config = Config()
@@ -0,0 +1,64 @@+import logging +import time +from contextlib import contextmanager + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +log = logging.getLogger("prami.db") + + +class Base(DeclarativeBase): + pass + + +_engine = None +_Session = None + + +def init_engine(database_url): + global _engine, _Session + + connect_args = {} + if database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + + _engine = create_engine( + database_url, + future=True, + pool_pre_ping=True, + connect_args=connect_args, + ) + _Session = sessionmaker(bind=_engine, expire_on_commit=False, future=True) + return _engine + + +def create_all(): + from . import models # noqa: F401 (registers the mappers) + + Base.metadata.create_all(_engine) + + +def wait_for_db(retries=10, delay=3): + for attempt in range(1, retries + 1): + try: + with _engine.connect() as conn: + conn.execute(text("SELECT 1")) + return + except Exception as exc: + log.warning("Database not ready (%s/%s): %s", attempt, retries, exc) + time.sleep(delay) + raise RuntimeError("Database did not become available") + + +@contextmanager +def session_scope(): + session = _Session() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close()
@@ -0,0 +1,34 @@+import random + +from .models import utcnow +from .state import clamp, compute_mood + + +def tick(pet): + if pet.asleep: + pet.energy = clamp(pet.energy + 12) + pet.hunger = clamp(pet.hunger + 3) + else: + pet.energy = clamp(pet.energy - 6) + pet.hunger = clamp(pet.hunger + 7) + + pet.cleanliness = clamp(pet.cleanliness - 5) + pet.social = clamp(pet.social - 6) + + strain = 0 + if pet.hunger > 75: + strain += 3 + if pet.cleanliness < 25: + strain += 2 + if pet.energy < 15: + strain += 2 + + if strain: + pet.health = clamp(pet.health - strain) + elif pet.hunger < 50 and pet.cleanliness > 50 and pet.energy > 40: + pet.health = clamp(pet.health + 1) + + pet.chaos = clamp(pet.chaos + random.randint(-4, 5)) + pet.mood = compute_mood(pet) + pet.last_decay_at = utcnow() + return pet
@@ -0,0 +1,90 @@+from datetime import timedelta + +from . import milestones +from . import relationships +from . import repository as repo +from . import responses +from .actions.base import ActionContext +from .actions.registry import REGISTRY +from .models import utcnow +from .result import Result + + +def handle(session, pet, user, command, config, status_id=None, now=None, arg=""): + now = now or utcnow() + settings = repo.get_settings(session, config) + action = REGISTRY.resolve(command or "unknown") + ctx = ActionContext(session, pet, user, arg or "", config, settings, now, status_id) + + if action.admin_only: + return _finalize(ctx, action, action.run(ctx)) + + if user.blocked or settings.paused: + return None + + if repo.count_global_interactions_last_hour(session, now) >= config.max_global_hour: + repo.log_event(session, pet.id, "rate_block_global", f"{user.acct} hit the global cap") + return None + if repo.count_user_interactions_last_hour(session, user.id, now) >= config.max_per_user_hour: + return Result(responses.too_much(pet), settings.default_visibility, False) + + if not action.enabled(config): + return _finalize(ctx, action, action.on_disabled(ctx)) + + if pet.asleep and not action.runs_while_asleep: + return Result(responses.too_sleepy(action.name, pet), settings.default_visibility, False) + + early = action.precheck(ctx) + if early is not None: + return _finalize(ctx, action, early) + + reason = _cooldown_reason(session, pet, user, action, now) + if reason: + return Result(responses.cooldown(action.name, reason, pet), settings.default_visibility, False) + + return _finalize(ctx, action, action.run(ctx)) + + +def _finalize(ctx, action, outcome): + if outcome is None: + return None + + session, pet, user = ctx.session, ctx.pet, ctx.user + achieved = [] + + if outcome.accepted: + _touch_cooldowns(session, pet, user, action, ctx.now) + if outcome.record: + repo.record_interaction(session, pet.id, user.id, action.name, ctx.status_id, True) + relationships.record(user, action.name, pet, outcome.was_critical) + user.last_seen = utcnow() + achieved = milestones.check(session, pet, user, action.name, outcome.was_critical) + + text = responses.maybe_address(outcome.text, user) if outcome.address else outcome.text + visibility = outcome.visibility or ctx.settings.default_visibility + return Result(text, visibility, outcome.accepted, outcome.favourite, outcome.boost, achieved) + + +def _cooldown_reason(session, pet, user, action, now): + if action.global_cooldown_minutes: + cd = repo.get_cooldown(session, pet.id, None, action.name) + if cd and now - cd.last_used_at < timedelta(minutes=action.global_cooldown_minutes): + return "global" + + if action.user_cooldown_minutes: + cd = repo.get_cooldown(session, pet.id, user.id, action.name) + if cd and now - cd.last_used_at < timedelta(minutes=action.user_cooldown_minutes): + return "user" + + if action.per_hour_limit is not None: + if repo.count_command_last_hour(session, action.name, now) >= action.per_hour_limit: + return "busy" + + return None + + +def _touch_cooldowns(session, pet, user, action, now): + if action.user_cooldown_minutes: + repo.touch_cooldown(session, pet.id, user.id, action.name, now) + if action.global_cooldown_minutes: + repo.touch_cooldown(session, pet.id, None, action.name, now)
@@ -0,0 +1,106 @@+import json +import random +from datetime import timedelta + +from sqlalchemy import func, select + +from .models import CommunityEvent, EventVote, utcnow +from .state import clamp +from .templates import EVENT_SCENARIOS as TEMPLATES + + +def active(session, now=None): + return session.scalars( + select(CommunityEvent).where(CommunityEvent.status == "active").order_by(CommunityEvent.id) + ).first() + + +def options_text(event): + return ", ".join(f"{key} ({opt['label']})" for key, opt in json.loads(event.options).items()) + + +def _active_count(session): + return session.scalar( + select(func.count()).select_from(CommunityEvent).where(CommunityEvent.status == "active") + ) or 0 + + +def _last_event_end(session): + return session.scalar( + select(func.max(CommunityEvent.ends_at)).where(CommunityEvent.status.in_(("completed", "expired"))) + ) + + +def start(session, pet, config, now=None, template=None): + now = now or utcnow() + tmpl = template or random.choice(TEMPLATES) + event = CommunityEvent( + pet_id=pet.id, + key=tmpl["key"], + title=tmpl["title"], + description=tmpl["description"], + options=json.dumps(tmpl["options"]), + status="active", + starts_at=now, + ends_at=now + timedelta(hours=config.event_duration_hours), + ) + session.add(event) + session.flush() + return event + + +def maybe_start(session, pet, config, now=None, force=False): + now = now or utcnow() + if active(session, now) or _active_count(session) >= config.max_active_events: + return None + if not force: + last_end = _last_event_end(session) + if last_end and now - last_end < timedelta(hours=config.event_cooldown_hours): + return None + if random.random() >= config.event_chance_per_autopost: + return None + return start(session, pet, config, now) + + +def vote(session, event, user, option): + options = json.loads(event.options) + option = (option or "").strip().lower() + if option not in options: + return False, "unknown option" + existing = session.scalars( + select(EventVote).where(EventVote.event_id == event.id, EventVote.user_id == user.id) + ).first() + if existing: + return False, "already voted" + session.add(EventVote(event_id=event.id, user_id=user.id, option=option)) + session.flush() + return True, "ok" + + +def complete(session, event, pet, now=None): + options = json.loads(event.options) + tally = {} + for v in session.scalars(select(EventVote).where(EventVote.event_id == event.id)): + tally[v.option] = tally.get(v.option, 0) + 1 + + order = list(options) + if tally: + winner = max(tally, key=lambda key: (tally[key], -order.index(key))) + else: + winner = order[0] + + outcome = options[winner] + for stat, delta in outcome.get("deltas", {}).items(): + setattr(pet, stat, clamp(getattr(pet, stat) + delta)) + + event.status = "completed" + event.outcome = outcome["text"] + return outcome["text"] + + +def expire_due(session, pet, now=None): + now = now or utcnow() + due = session.scalars( + select(CommunityEvent).where(CommunityEvent.status == "active", CommunityEvent.ends_at <= now) + ) + return [complete(session, event, pet, now) for event in list(due)]
@@ -0,0 +1,32 @@+import logging + +from mastodon import Mastodon + +log = logging.getLogger("prami.mastodon") + + +class PramiClient: + def __init__(self, config): + self.config = config + self.api = Mastodon( + access_token=config.mastodon_access_token, + api_base_url=config.mastodon_base_url, + ratelimit_method="pace", + ) + self.me = self.api.account_verify_credentials() + log.info("Authenticated as @%s (id=%s)", self.me.acct, self.me.id) + + def fetch_mentions(self, since_id=None): + return self.api.notifications(types=["mention"], since_id=since_id, limit=30) + + def reply(self, status, text, visibility): + return self.api.status_post(text, in_reply_to_id=status.id, visibility=visibility) + + def post(self, text, visibility): + return self.api.status_post(text, visibility=visibility) + + def favourite(self, status): + return self.api.status_favourite(status.id) + + def boost(self, status): + return self.api.status_reblog(status.id)
@@ -0,0 +1,71 @@+import random + +from sqlalchemy import select + +from . import sanitize +from .models import MemorySuggestion, utcnow + +CATEGORIES = ("phrase", "preference", "fear", "lore", "observation") + + +def categorize(content): + low = content.lower() + if any(word in low for word in ("afraid", "fear", "scared", "terrified")): + return "fear" + if any(word in low for word in ("likes", "prefers", "loves", "hates", "favourite", "favorite")): + return "preference" + if content.strip()[:1] in "\"'" or "says" in low: + return "phrase" + if any(word in low for word in ("believes", "thinks", "knows")): + return "lore" + return "observation" + + +def submit(session, pet, user, raw, config): + content = sanitize.clean_memory(raw, config.memory_max_length, config.memory_allow_urls) + if not content: + return None + suggestion = MemorySuggestion( + pet_id=pet.id, + submitted_by_user_id=user.id, + content=content, + category=categorize(content), + ) + session.add(suggestion) + session.flush() + return suggestion + + +def by_status(session, status): + stmt = select(MemorySuggestion).where(MemorySuggestion.status == status).order_by(MemorySuggestion.id) + return list(session.scalars(stmt)) + + +def get(session, suggestion_id): + return session.get(MemorySuggestion, suggestion_id) + + +def approve(session, suggestion_id, reviewer): + suggestion = get(session, suggestion_id) + if suggestion is None or suggestion.status != "pending": + return None + suggestion.status = "approved" + suggestion.reviewed_at = utcnow() + suggestion.reviewed_by = reviewer + return suggestion + + +def reject(session, suggestion_id, reviewer, reason=""): + suggestion = get(session, suggestion_id) + if suggestion is None or suggestion.status != "pending": + return None + suggestion.status = "rejected" + suggestion.reviewed_at = utcnow() + suggestion.reviewed_by = reviewer + suggestion.rejection_reason = (reason or "").strip()[:180] or None + return suggestion + + +def random_approved(session): + approved = by_status(session, "approved") + return random.choice(approved) if approved else None
@@ -0,0 +1,63 @@+from sqlalchemy import select + +from . import repository as repo +from .models import Milestone +from .relationships import CARE_COMMANDS + + +def _already(session, pet_id, user_id, key): + stmt = select(Milestone).where(Milestone.pet_id == pet_id, Milestone.key == key) + stmt = stmt.where(Milestone.user_id.is_(None) if user_id is None else Milestone.user_id == user_id) + return session.scalars(stmt).first() is not None + + +def check(session, pet, user, command, was_critical): + found = [] + + def award(key, scope, user_id, text, favourite=False, boost=False): + if _already(session, pet.id, user_id, key): + return + session.add(Milestone(pet_id=pet.id, user_id=user_id, key=key, scope=scope)) + session.flush() + found.append({"key": key, "scope": scope, "text": text, "favourite": favourite, "boost": boost}) + + name = pet.name + + if user.total_interactions == 1: + award("first_interaction", "user", user.id, f"A new face met {name} today. Welcome, @{user.acct}.") + if user.total_interactions == 10: + award("user_10", "user", user.id, f"@{user.acct} has looked after {name} ten times now. {name} noticed.") + if user.total_interactions == 25: + award("user_25", "user", user.id, f"@{user.acct} hit 25 interactions with {name}. That's basically family.") + if was_critical and command in CARE_COMMANDS: + award("first_critical_save", "user", user.id, + f"@{user.acct} helped {name} in a rough moment. {name} will remember this.", favourite=True) + if user.bond_level == "trusted": + award("reached_trusted", "user", user.id, f"@{user.acct} and {name} reached 'trusted'. Hard-won and real.") + if user.bond_level == "friend": + award("reached_friend", "user", user.id, f"@{user.acct} is officially {name}'s friend now. Big deal, quietly.") + + if command == "feed" and repo.count_interactions_total(session, "feed") == 1: + award("first_feeding", "community", None, f"{name} was fed for the very first time. A historic snack.", favourite=True) + if command == "clean" and repo.count_interactions_total(session, "clean") == 1: + award("first_bath_survived", "community", None, f"{name} survived its first bath. Furious, clean, alive.") + if repo.count_interactions_total(session) >= 100: + award("100_interactions", "community", None, f"The community has cared for {name} 100 times. Thank you, all of you.", favourite=True, boost=True) + if pet.age_days >= 7: + award("7_days_alive", "community", None, f"{name} has been alive for a week. Seven whole days of tiny chaos.") + if was_critical and command == "feed": + award("saved_from_critical_hunger", "community", None, f"{name} was saved from going truly hungry. Good work, everyone.", favourite=True) + if pet.chaos >= 100: + award("chaos_100", "community", None, f"{name}'s chaos hit maximum and nobody was harmed. Mostly.") + + return found + + +def award_event_completed(session, pet): + if _already(session, pet.id, None, "first_event_completed"): + return None + session.add(Milestone(pet_id=pet.id, user_id=None, key="first_event_completed", scope="community")) + session.flush() + return {"key": "first_event_completed", "scope": "community", + "text": f"{pet.name} completed its first community event. Democracy survived.", + "favourite": False, "boost": False}
@@ -0,0 +1,189 @@+from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +def utcnow(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class Pet(Base): + __tablename__ = "pets" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(80)) + species: Mapped[str] = mapped_column(String(120)) + personality: Mapped[str] = mapped_column(String(255)) + + hunger: Mapped[int] = mapped_column(default=40) + happiness: Mapped[int] = mapped_column(default=60) + energy: Mapped[int] = mapped_column(default=70) + health: Mapped[int] = mapped_column(default=90) + cleanliness: Mapped[int] = mapped_column(default=70) + social: Mapped[int] = mapped_column(default=50) + trust: Mapped[int] = mapped_column(default=30) + chaos: Mapped[int] = mapped_column(default=20) + + asleep: Mapped[bool] = mapped_column(Boolean, default=False) + mood: Mapped[str] = mapped_column(String(40), default="content") + + last_autopost_category: Mapped[str | None] = mapped_column(String(40), nullable=True) + last_autopost_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_decay_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + born_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) + + @property + def age_days(self): + return max(0, (utcnow() - self.born_at).days) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + acct: Mapped[str] = mapped_column(String(255), unique=True, index=True) + mastodon_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + display_name: Mapped[str] = mapped_column(String(255), default="") + nickname: Mapped[str | None] = mapped_column(String(32), nullable=True) + blocked: Mapped[bool] = mapped_column(Boolean, default=False) + + total_interactions: Mapped[int] = mapped_column(default=0) + feed_count: Mapped[int] = mapped_column(default=0) + play_count: Mapped[int] = mapped_column(default=0) + pet_count: Mapped[int] = mapped_column(default=0) + clean_count: Mapped[int] = mapped_column(default=0) + sleep_count: Mapped[int] = mapped_column(default=0) + wake_count: Mapped[int] = mapped_column(default=0) + + trust_score: Mapped[int] = mapped_column(default=0) + bond_level: Mapped[str] = mapped_column(String(20), default="stranger") + last_milestone_seen: Mapped[str | None] = mapped_column(String(60), nullable=True) + + first_seen: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + last_seen: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + +class Interaction(Base): + __tablename__ = "interactions" + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + command: Mapped[str] = mapped_column(String(40), index=True) + status_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + accepted: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) + + user: Mapped[User] = relationship() + + +class Cooldown(Base): + __tablename__ = "cooldowns" + __table_args__ = (UniqueConstraint("pet_id", "user_id", "command", name="uq_cooldown_scope"),) + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + command: Mapped[str] = mapped_column(String(40)) + last_used_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + +class ProcessedStatus(Base): + __tablename__ = "processed_statuses" + + status_id: Mapped[str] = mapped_column(String(64), primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + +class Event(Base): + __tablename__ = "events" + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int | None] = mapped_column(ForeignKey("pets.id"), nullable=True) + kind: Mapped[str] = mapped_column(String(60), index=True) + detail: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) + + +class Settings(Base): + __tablename__ = "settings" + + id: Mapped[int] = mapped_column(primary_key=True) + paused: Mapped[bool] = mapped_column(Boolean, default=False) + autopost_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + favourites_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + boosts_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + default_visibility: Mapped[str] = mapped_column(String(20), default="unlisted") + autopost_visibility: Mapped[str] = mapped_column(String(20), default="unlisted") + + +class SocialAction(Base): + __tablename__ = "social_actions" + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + status_id: Mapped[str] = mapped_column(String(64), index=True) + action: Mapped[str] = mapped_column(String(20), index=True) + reason: Mapped[str] = mapped_column(String(120), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) + + +class MemorySuggestion(Base): + __tablename__ = "memory_suggestions" + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + content: Mapped[str] = mapped_column(String(255)) + category: Mapped[str] = mapped_column(String(20), default="lore") + status: Mapped[str] = mapped_column(String(12), default="pending", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + reviewed_by: Mapped[str | None] = mapped_column(String(255), nullable=True) + rejection_reason: Mapped[str | None] = mapped_column(String(180), nullable=True) + + +class CommunityEvent(Base): + __tablename__ = "community_events" + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + key: Mapped[str] = mapped_column(String(60)) + title: Mapped[str] = mapped_column(String(120)) + description: Mapped[str] = mapped_column(String(255)) + options: Mapped[str] = mapped_column(Text, default="{}") + status: Mapped[str] = mapped_column(String(12), default="active", index=True) + outcome: Mapped[str | None] = mapped_column(String(255), nullable=True) + starts_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + ends_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + +class EventVote(Base): + __tablename__ = "event_votes" + __table_args__ = (UniqueConstraint("event_id", "user_id", name="uq_event_vote"),) + + id: Mapped[int] = mapped_column(primary_key=True) + event_id: Mapped[int] = mapped_column(ForeignKey("community_events.id"), index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + option: Mapped[str] = mapped_column(String(40)) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + +class Milestone(Base): + __tablename__ = "milestones" + __table_args__ = (UniqueConstraint("pet_id", "user_id", "key", name="uq_milestone"),) + + id: Mapped[int] = mapped_column(primary_key=True) + pet_id: Mapped[int] = mapped_column(ForeignKey("pets.id"), index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + key: Mapped[str] = mapped_column(String(60)) + scope: Mapped[str] = mapped_column(String(12), default="community") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
@@ -0,0 +1,51 @@+import re +from dataclasses import dataclass +from html import unescape + +from .actions.registry import REGISTRY + +# Command vocabulary comes from the action registry, so parsing and execution +# never drift apart. +_ALIAS_TO_COMMAND = REGISTRY.alias_map() +PHRASE_ALIASES = REGISTRY.phrase_map() + +_MENTION = re.compile(r"@[\w.\-]+(?:@[\w.\-]+)?") +_WHITESPACE = re.compile(r"\s+") + + +@dataclass +class Parsed: + name: str | None + arg: str = "" + + +def strip_html(content): + text = re.sub(r"(?i)<br\s*/?>", " ", content) + text = re.sub(r"(?i)</p>", " ", text) + text = re.sub(r"<[^>]+>", "", text) + return unescape(text) + + +def _after(raw, keyword): + match = re.search(r"\b" + re.escape(keyword) + r"\b", raw, re.IGNORECASE) + return raw[match.end():].strip() if match else "" + + +def parse(content): + raw = _WHITESPACE.sub(" ", strip_html(content)).strip() + detect = _WHITESPACE.sub(" ", _MENTION.sub(" ", raw)).strip().lower() + + for phrase, command in PHRASE_ALIASES.items(): + if detect.startswith(phrase): + return Parsed(command, _after(raw, phrase)) + + for token in detect.split(): + command = _ALIAS_TO_COMMAND.get(token) + if command: + return Parsed(command, _after(raw, token)) + + return Parsed(None) + + +def parse_command(content): + return parse(content).name
@@ -0,0 +1,53 @@+from dataclasses import dataclass +from functools import lru_cache + +KNOWN_TRAITS = ( + "weird", + "affectionate", + "dramatic", + "suspicious", + "chaotic", + "calm", + "shy", + "clingy", + "grumpy", + "brave", +) + + +@dataclass(frozen=True) +class Personality: + traits: frozenset + + def has(self, trait): + return trait in self.traits + + @property + def chaos_threshold(self): + if "chaotic" in self.traits: + return 55 + if "calm" in self.traits: + return 82 + return 70 + + @property + def suspicion_threshold(self): + if "suspicious" in self.traits: + return 35 + if "affectionate" in self.traits or "clingy" in self.traits: + return 18 + return 25 + + @property + def tired_threshold(self): + return 30 if "dramatic" in self.traits else 22 + + @property + def happy_threshold(self): + return 62 if "affectionate" in self.traits else 70 + + +@lru_cache(maxsize=64) +def from_string(text): + low = (text or "").lower() + return Personality(frozenset(t for t in KNOWN_TRAITS if t in low))
@@ -0,0 +1,41 @@+from .state import clamp + +BOND_LEVELS = ("stranger", "familiar", "trusted", "friend", "beloved menace") + +CARE_COMMANDS = ("feed", "play", "pet", "clean", "sleep", "wake") +_COUNT_ATTR = {command: f"{command}_count" for command in CARE_COMMANDS} + +TRUST_GAIN = {"feed": 3, "play": 2, "pet": 4, "clean": 3, "sleep": 1, "wake": 1, "nickname": 1} +CRITICAL_BONUS = 6 + + +def is_critical(pet): + return pet.hunger >= 85 or pet.health <= 20 or pet.cleanliness <= 15 or pet.energy <= 10 + + +def bond_for(trust_score): + if trust_score >= 80: + return "beloved menace" + if trust_score >= 55: + return "friend" + if trust_score >= 30: + return "trusted" + if trust_score >= 12: + return "familiar" + return "stranger" + + +def record(user, command, pet, was_critical=False): + user.total_interactions += 1 + + attr = _COUNT_ATTR.get(command) + if attr: + setattr(user, attr, getattr(user, attr) + 1) + + gain = TRUST_GAIN.get(command, 0) + if attr and was_critical: + gain += CRITICAL_BONUS + if gain: + user.trust_score = clamp(user.trust_score + gain, 0, 100) + + user.bond_level = bond_for(user.trust_score)
@@ -0,0 +1,227 @@+from datetime import timedelta + +from sqlalchemy import delete, func, select + +from .models import ( + Cooldown, + CommunityEvent, + Event, + EventVote, + Interaction, + MemorySuggestion, + Milestone, + Pet, + ProcessedStatus, + Settings, + SocialAction, + User, + utcnow, +) + + +def get_pet(session): + return session.scalars(select(Pet).order_by(Pet.id)).first() + + +def get_or_create_pet(session, config): + pet = get_pet(session) + if pet is None: + pet = Pet( + name=config.pet_name, + species=config.pet_species, + personality=config.pet_personality, + ) + session.add(pet) + session.flush() + return pet + + +def get_user(session, acct): + return session.scalars(select(User).where(User.acct == acct.lstrip("@"))).first() + + +def get_or_create_user(session, acct, mastodon_id=None, display_name=""): + acct = acct.lstrip("@") + user = session.scalars(select(User).where(User.acct == acct)).first() + if user is None: + user = User(acct=acct, mastodon_id=mastodon_id, display_name=display_name) + session.add(user) + session.flush() + else: + user.last_seen = utcnow() + if mastodon_id and not user.mastodon_id: + user.mastodon_id = mastodon_id + if display_name: + user.display_name = display_name + return user + + +def block_user(session, acct): + user = get_or_create_user(session, acct) + user.blocked = True + return user + + +def is_status_processed(session, status_id): + return session.get(ProcessedStatus, str(status_id)) is not None + + +def mark_status_processed(session, status_id): + session.add(ProcessedStatus(status_id=str(status_id))) + + +def prune_processed_statuses(session, before): + result = session.execute(delete(ProcessedStatus).where(ProcessedStatus.created_at < before)) + return result.rowcount or 0 + + +def record_interaction(session, pet_id, user_id, command, status_id, accepted): + session.add( + Interaction( + pet_id=pet_id, + user_id=user_id, + command=command, + status_id=str(status_id) if status_id else None, + accepted=accepted, + ) + ) + + +def _since(now): + return (now or utcnow()) - timedelta(hours=1) + + +def count_user_interactions_last_hour(session, user_id, now=None): + stmt = ( + select(func.count()) + .select_from(Interaction) + .where( + Interaction.user_id == user_id, + Interaction.accepted.is_(True), + Interaction.created_at >= _since(now), + ) + ) + return session.scalar(stmt) or 0 + + +def count_global_interactions_last_hour(session, now=None): + stmt = ( + select(func.count()) + .select_from(Interaction) + .where( + Interaction.accepted.is_(True), + Interaction.created_at >= _since(now), + ) + ) + return session.scalar(stmt) or 0 + + +def count_interactions_total(session, command=None): + stmt = select(func.count()).select_from(Interaction).where(Interaction.accepted.is_(True)) + if command: + stmt = stmt.where(Interaction.command == command) + return session.scalar(stmt) or 0 + + +def count_command_last_hour(session, command, now=None): + stmt = ( + select(func.count()) + .select_from(Interaction) + .where( + Interaction.command == command, + Interaction.accepted.is_(True), + Interaction.created_at >= _since(now), + ) + ) + return session.scalar(stmt) or 0 + + +def get_cooldown(session, pet_id, user_id, command): + stmt = select(Cooldown).where( + Cooldown.pet_id == pet_id, + Cooldown.command == command, + Cooldown.user_id.is_(None) if user_id is None else Cooldown.user_id == user_id, + ) + return session.scalars(stmt).first() + + +def touch_cooldown(session, pet_id, user_id, command, now=None): + now = now or utcnow() + cd = get_cooldown(session, pet_id, user_id, command) + if cd is None: + session.add(Cooldown(pet_id=pet_id, user_id=user_id, command=command, last_used_at=now)) + else: + cd.last_used_at = now + + +def log_event(session, pet_id, kind, detail=""): + session.add(Event(pet_id=pet_id, kind=kind, detail=detail)) + + +def get_settings(session, config): + settings = session.scalars(select(Settings).order_by(Settings.id)).first() + if settings is None: + settings = Settings( + autopost_enabled=config.autopost_enabled, + favourites_enabled=config.enable_favourites, + boosts_enabled=config.enable_boosts, + default_visibility=config.default_visibility, + autopost_visibility=config.autopost_visibility, + ) + session.add(settings) + session.flush() + return settings + + +def has_social_action(session, status_id, action): + stmt = select(SocialAction).where( + SocialAction.status_id == str(status_id), SocialAction.action == action + ) + return session.scalars(stmt).first() is not None + + +def record_social_action(session, pet_id, user_id, status_id, action, reason=""): + session.add( + SocialAction( + pet_id=pet_id, + user_id=user_id, + status_id=str(status_id), + action=action, + reason=reason, + ) + ) + + +def count_social_actions(session, action, since, user_id=None): + stmt = select(func.count()).select_from(SocialAction).where( + SocialAction.action == action, SocialAction.created_at >= since + ) + if user_id is not None: + stmt = stmt.where(SocialAction.user_id == user_id) + return session.scalar(stmt) or 0 + + +def last_social_action_at(session, action): + stmt = select(func.max(SocialAction.created_at)).where(SocialAction.action == action) + return session.scalar(stmt) + + +def reset_world(session, config): + for model in ( + EventVote, + CommunityEvent, + Milestone, + MemorySuggestion, + SocialAction, + Cooldown, + Interaction, + Event, + ProcessedStatus, + Settings, + User, + ): + session.execute(delete(model)) + session.execute(delete(Pet)) + session.flush() + get_settings(session, config) + return get_or_create_pet(session, config)
@@ -0,0 +1,230 @@+import random + +from . import personality as personality_mod +from . import templates +from .config import config + +# Back-compatible names; the data lives in templates.py. +TEMPLATES = templates.COMMAND_TEMPLATES +AUTOPOST = templates.AUTOPOST + +PACKS = {"default": (templates.COMMAND_TEMPLATES, templates.AUTOPOST)} + + +def _pack(): + return PACKS.get(config.personality_pack, PACKS["default"]) + + +def _personality(pet): + text = getattr(pet, "personality", None) or config.pet_personality + return personality_mod.from_string(text) + + +def _render(pool, pet): + line = random.choice(pool) + return line.format(name=pet.name) if "{" in line else line + + +def variant_for(command, pet): + p = _personality(pet) + if command == "feed": + if pet.hunger >= 75: + return "very_hungry" + if pet.hunger <= 12: + return "already_full" + if pet.chaos >= p.chaos_threshold: + return "chaotic" + if pet.trust <= p.suspicion_threshold: + return "suspicious" + if pet.happiness >= p.happy_threshold: + return "happy" + return "normal" + + if command == "play": + if pet.energy <= p.tired_threshold: + return "tired" + if pet.chaos >= p.chaos_threshold: + return "chaotic" + if pet.happiness >= p.happy_threshold or pet.energy >= 75: + return "energetic" + return "normal" + + if command == "pet": + if pet.asleep: + return "asleep" + if pet.trust <= p.suspicion_threshold: + return "low_trust" + if pet.happiness < 30 or pet.chaos >= p.chaos_threshold: + return "annoyed" + if pet.trust >= 70: + return "high_trust" + if pet.happiness >= p.happy_threshold: + return "affectionate" + return "normal" + + if command == "clean": + if pet.cleanliness >= 85: + return "already_clean" + if pet.chaos >= p.chaos_threshold: + return "chaotic" + if pet.cleanliness <= 30: + return "dirty" + return "normal" if pet.trust >= 60 else "angry" + + if command == "sleep": + if pet.energy >= 80: + return "too_energetic" + if pet.energy <= 30: + return "grateful" + return "normal" + + if command == "wake": + if pet.energy < 20: + return "too_tired" + if pet.chaos >= p.chaos_threshold or pet.happiness < 35: + return "annoyed" + return "normal" + + return "normal" + + +def _status_variant(pet, p): + if pet.asleep: + return "asleep" + if pet.hunger >= 75: + return "hungry" + if pet.health < 30 or pet.happiness < 30: + return "sad" + if pet.energy <= 25: + return "tired" + if pet.chaos >= p.chaos_threshold: + return "chaotic" + if pet.cleanliness <= 30: + return "dirty" + if pet.happiness >= p.happy_threshold: + return "happy" + return "normal" + + +def for_command(command, pet): + groups = _pack()[0][command] + variant = variant_for(command, pet) + return _render(groups.get(variant) or groups["normal"], pet) + + +def status(pet): + groups = _pack()[0]["status"] + variant = _status_variant(pet, _personality(pet)) + return _render(groups.get(variant) or groups["normal"], pet) + + +def already_asleep(pet): + return _render(_pack()[0]["sleep"]["already_asleep"], pet) + + +def already_awake(pet): + return _render(_pack()[0]["wake"]["already_awake"], pet) + + +def too_sleepy(command, pet): + groups = _pack()[0].get(command, {}) + return _render(groups.get("asleep") or templates.ASLEEP_REJECTION, pet) + + +def cooldown(command, reason, pet): + return _render(templates.COOLDOWN.get(reason, templates.COOLDOWN["global"]), pet) + + +def too_much(pet): + return _render(templates.TOO_MUCH, pet) + + +def unknown(pet): + return _render(templates.UNKNOWN, pet) + + +def nickname_set(pet, nick): + return random.choice(templates.NICKNAME_SET).format(name=pet.name, nick=nick) + + +def nickname_cleared(pet): + return _render(templates.NICKNAME_CLEARED, pet) + + +def nickname_rejected(pet): + return _render(templates.NICKNAME_REJECTED, pet) + + +def teach_ack(pet): + return _render(templates.TEACH_ACK, pet) + + +def teach_disabled(pet): + return _render(templates.TEACH_DISABLED, pet) + + +def teach_rejected(pet): + return _render(templates.TEACH_REJECTED, pet) + + +def no_event(pet): + return _render(templates.NO_EVENT, pet) + + +def event_show(pet, title, description, options): + return templates.EVENT_SHOW.format(name=pet.name, title=title, description=description, options=options) + + +def event_help(pet, title, options): + return templates.EVENT_HELP.format(name=pet.name, title=title, options=options) + + +def vote_ack(pet, title): + return random.choice(templates.VOTE_ACK).format(name=pet.name, title=title) + + +def vote_rejected(pet, reason, options): + if reason == "already voted": + return templates.VOTE_REJECTED_ALREADY.format(name=pet.name) + return templates.VOTE_REJECTED_UNKNOWN.format(name=pet.name, options=options) + + +def maybe_address(text, user): + nick = getattr(user, "nickname", None) + if not nick: + return text + warm = getattr(user, "bond_level", "stranger") in ("friend", "beloved menace") + if random.random() < (0.3 if warm else 0.15): + return f"{nick}, {text}" + return text + + +def autopost_group(pet): + p = _personality(pet) + if pet.asleep: + return "sleepy" + if pet.hunger >= 70: + return "hungry" + if pet.energy <= 25: + return "sleepy" + if pet.chaos >= p.chaos_threshold: + return "chaotic" + if pet.cleanliness <= 30: + return "dirty" + if pet.social <= 25: + return "lonely" + if pet.happiness >= p.happy_threshold: + return "happy" + return "weird_observation" + + +def autopost(pet): + posts = _pack()[1] + group = autopost_group(pet) + flavor = ("weird_observation", "existential", "community_appreciation") + keys = [group] * 4 + list(flavor) + return _render(posts[random.choice(keys)], pet) + + +def help_text(config): + return templates.HELP_TEXT.format(name=config.pet_name, species=config.pet_species)
@@ -0,0 +1,11 @@+from dataclasses import dataclass, field + + +@dataclass +class Result: + text: str + visibility: str + accepted: bool + favourite: bool = False + boost: bool = False + milestones: list = field(default_factory=list)
@@ -0,0 +1,60 @@+from datetime import timedelta + +from . import repository as repo +from .models import utcnow +from .sanitize import clean_nickname + +PRIVATE_VISIBILITIES = ("private", "direct") + + +def may_reply(settings, user): + return not settings.paused and not user.blocked + + +def may_autopost(settings): + return settings.autopost_enabled and not settings.paused + + +def may_use_nickname(nickname): + return clean_nickname(nickname) is not None + + +def may_store_memory(config): + return config.enable_teach_command + + +def may_favourite(session, settings, config, user, visibility, status_id, now=None): + now = now or utcnow() + if not settings.favourites_enabled or settings.paused: + return False, "favourites disabled" + if user is None or user.blocked: + return False, "blocked user" + if visibility in PRIVATE_VISIBILITIES and not config.allow_favourite_private: + return False, "private status" + if repo.has_social_action(session, status_id, "favourite"): + return False, "already favourited" + if repo.count_social_actions(session, "favourite", now - timedelta(hours=1)) >= config.max_favourites_per_hour: + return False, "hourly cap reached" + if repo.count_social_actions(session, "favourite", now - timedelta(days=1), user.id) >= config.max_favourites_per_user_per_day: + return False, "per-user daily cap reached" + return True, "ok" + + +def may_boost(session, settings, config, user, visibility, status_id, now=None): + now = now or utcnow() + if not settings.boosts_enabled or settings.paused: + return False, "boosts disabled" + if user is None or user.blocked: + return False, "blocked user" + if visibility in PRIVATE_VISIBILITIES: + return False, "private status" + if config.boost_only_public_statuses and visibility != "public": + return False, "not a public status" + if repo.has_social_action(session, status_id, "boost"): + return False, "already boosted" + if repo.count_social_actions(session, "boost", now - timedelta(days=1)) >= config.max_boosts_per_day: + return False, "daily boost cap reached" + last = repo.last_social_action_at(session, "boost") + if last and now - last < timedelta(hours=config.min_hours_between_boosts): + return False, "too soon since last boost" + return True, "ok"
@@ -0,0 +1,31 @@+import re + +_TAG = re.compile(r"<[^>]+>") +_URL = re.compile(r"https?://|www\.", re.IGNORECASE) +_WHITESPACE = re.compile(r"\s+") + +NICKNAME_MAX = 32 + + +def clean_nickname(raw): + if not raw: + return None + text = _WHITESPACE.sub(" ", _TAG.sub("", raw)).strip() + if not text: + return None + if "@" in text or _URL.search(text): + return None + return text[:NICKNAME_MAX] + + +def clean_memory(raw, max_len=180, allow_urls=False): + if not raw: + return None + text = _WHITESPACE.sub(" ", _TAG.sub("", raw)).strip() + if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]: + text = text[1:-1].strip() + if not text or "@" in text: + return None + if not allow_urls and _URL.search(text): + return None + return text[:max_len]
@@ -0,0 +1,82 @@+import logging +from datetime import timedelta + +from apscheduler.schedulers.background import BackgroundScheduler + +from . import autopost, decay, events, milestones, safety +from . import repository as repo +from .config import config +from .db import session_scope +from .models import utcnow + +log = logging.getLogger("prami.scheduler") + + +def run_decay(): + with session_scope() as session: + pet = repo.get_pet(session) + if pet is None: + return + decay.tick(pet) + log.info("Decay — mood=%s hunger=%s energy=%s clean=%s", pet.mood, pet.hunger, pet.energy, pet.cleanliness) + + +def run_autopost(client): + posts = [] + with session_scope() as session: + settings = repo.get_settings(session, config) + pet = repo.get_pet(session) + if pet is None or not safety.may_autopost(settings): + return + visibility = settings.autopost_visibility + + if config.enable_events: + for outcome in events.expire_due(session, pet, utcnow()): + milestones.award_event_completed(session, pet) + posts.append(outcome) + started = events.maybe_start(session, pet, config, utcnow()) + if started: + posts.append( + f"{started.title} {started.description} " + f"Vote with '@{pet.name} vote <option>' — {events.options_text(started)}." + ) + + message = autopost.compose(session, pet, config) + repo.log_event(session, pet.id, "autopost", message) + posts.append(message) + + for text in posts: + try: + client.post(text, visibility) + except Exception: + log.exception("Failed to autopost") + log.info("Autoposted %s message(s) (%s)", len(posts), visibility) + + +def run_maintenance(): + cutoff = utcnow() - timedelta(days=config.processed_retention_days) + with session_scope() as session: + removed = repo.prune_processed_statuses(session, cutoff) + if removed: + log.info("Pruned %s processed-status rows older than %s days", removed, config.processed_retention_days) + + +def build_scheduler(client): + scheduler = BackgroundScheduler(timezone="UTC") + scheduler.add_job( + run_decay, "interval", minutes=config.decay_interval, id="decay", max_instances=1, coalesce=True + ) + if config.autopost_enabled: + scheduler.add_job( + run_autopost, + "interval", + minutes=config.autopost_interval, + args=[client], + id="autopost", + max_instances=1, + coalesce=True, + ) + scheduler.add_job( + run_maintenance, "interval", hours=24, id="maintenance", max_instances=1, coalesce=True + ) + return scheduler
@@ -0,0 +1,12 @@+def favourite_worthy(command, before, user): + if command == "feed" and before["hunger"] >= 80: + return True + if command == "pet" and before["social"] <= 20: + return True + if command == "clean" and before["cleanliness"] <= 15: + return True + return False + + +def boost_worthy(before_critical, after_critical): + return before_critical and not after_critical
@@ -0,0 +1,24 @@+def clamp(value, low=0, high=100): + return max(low, min(high, int(value))) + + +def compute_mood(pet): + if pet.asleep: + return "asleep" + if pet.health < 25: + return "unwell" + if pet.hunger > 75: + return "ravenous" + if pet.energy < 20: + return "exhausted" + if pet.cleanliness < 25: + return "filthy" + if pet.chaos > 75: + return "feral" + if pet.happiness > 75 and pet.social > 60: + return "delighted" + if pet.social < 20: + return "lonely" + if pet.happiness < 30: + return "grumpy" + return "content"
@@ -0,0 +1,137 @@+from datetime import timedelta + +from sqlalchemy import func, select, text + +from . import events, memory +from . import repository as repo +from .models import Event, Interaction, User, utcnow + + +def collect(session, config, settings, now=None): + now = now or utcnow() + pet = repo.get_pet(session) + day_ago = now - timedelta(hours=24) + + last_startup = session.scalar(select(func.max(Event.created_at)).where(Event.kind == "startup")) + + interactions_24h = session.scalar( + select(func.count()).select_from(Interaction).where( + Interaction.accepted.is_(True), Interaction.created_at >= day_ago + ) + ) or 0 + unique_users_24h = session.scalar( + select(func.count(func.distinct(Interaction.user_id))).where( + Interaction.accepted.is_(True), Interaction.created_at >= day_ago + ) + ) or 0 + autoposts_24h = session.scalar( + select(func.count()).select_from(Event).where(Event.kind == "autopost", Event.created_at >= day_ago) + ) or 0 + blocked = session.scalar(select(func.count()).select_from(User).where(User.blocked.is_(True))) or 0 + + top = session.execute( + select(Interaction.command, func.count().label("n")) + .where(Interaction.accepted.is_(True)) + .group_by(Interaction.command) + .order_by(func.count().desc()) + .limit(5) + ).all() + + active = events.active(session, now) + + return { + "uptime_seconds": (now - last_startup).total_seconds() if last_startup else None, + "total_interactions": repo.count_interactions_total(session), + "interactions_24h": interactions_24h, + "unique_users_24h": unique_users_24h, + "mood": pet.mood, + "critical_stats": _critical_stats(pet), + "key_stats": { + "hunger": pet.hunger, "energy": pet.energy, "health": pet.health, + "cleanliness": pet.cleanliness, "social": pet.social, + }, + "autoposts_24h": autoposts_24h, + "favourites_24h": repo.count_social_actions(session, "favourite", day_ago), + "boosts_24h": repo.count_social_actions(session, "boost", day_ago), + "active_event": active.title if active else None, + "pending_memories": len(memory.by_status(session, "pending")), + "blocked_users": blocked, + "top_commands": [(row[0], row[1]) for row in top], + "last_decay_at": pet.last_decay_at, + "last_autopost_at": pet.last_autopost_at, + "last_autopost_category": pet.last_autopost_category, + "database": _database_status(session, config), + } + + +def _critical_stats(pet): + breaches = [] + if pet.hunger >= 85: + breaches.append(f"hunger {pet.hunger}") + if pet.health <= 20: + breaches.append(f"health {pet.health}") + if pet.cleanliness <= 15: + breaches.append(f"cleanliness {pet.cleanliness}") + if pet.energy <= 10: + breaches.append(f"energy {pet.energy}") + return breaches + + +def _database_status(session, config): + try: + session.execute(text("SELECT 1")) + dialect = config.database_url.split("://", 1)[0].split("+")[0] or "unknown" + return f"connected ({dialect})" + except Exception as exc: + return f"error: {exc}" + + +def _fmt_dt(value): + return f"{value:%Y-%m-%d %H:%M} UTC" if value else "never" + + +def _fmt_uptime(seconds): + if seconds is None: + return "—" + total = int(seconds) + days, total = divmod(total, 86400) + hours, total = divmod(total, 3600) + minutes = total // 60 + parts = [] + if days: + parts.append(f"{days}d") + if hours or days: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + + +def render(data): + keys = data["key_stats"] + critical = ", ".join(data["critical_stats"]) or "none" + top = ", ".join(f"{cmd}:{n}" for cmd, n in data["top_commands"]) or "none" + last_post = _fmt_dt(data["last_autopost_at"]) + if data["last_autopost_category"]: + last_post += f" ({data['last_autopost_category']})" + + return "\n".join([ + "Prami — local stats", + f"uptime: {_fmt_uptime(data['uptime_seconds'])}", + f"mood: {data['mood']} · critical: {critical}", + f"stats: hunger={keys['hunger']} energy={keys['energy']} health={keys['health']} " + f"clean={keys['cleanliness']} social={keys['social']}", + f"interactions: {data['total_interactions']} total · {data['interactions_24h']} in 24h · " + f"{data['unique_users_24h']} unique users 24h", + f"top commands: {top}", + f"autoposts 24h: {data['autoposts_24h']} · favourites 24h: {data['favourites_24h']} · " + f"boosts 24h: {data['boosts_24h']}", + f"active event: {data['active_event'] or 'none'}", + f"pending memories: {data['pending_memories']} · blocked users: {data['blocked_users']}", + f"last decay: {_fmt_dt(data['last_decay_at'])}", + f"last autopost: {last_post}", + f"database: {data['database']}", + ]) + + +def text_report(session, config, settings, now=None): + return render(collect(session, config, settings, now))
@@ -0,0 +1,384 @@+"""All of Prami's authorable text lives here. See docs/VOICE_AND_PERSONALITY.md +before adding or editing lines. The logic that picks between them lives in responses.py.""" + +COMMAND_TEMPLATES = { + "status": { + "normal": [ + "{name} is awake, mildly suspicious of nothing in particular, and staring at the corner like the corner owes it money.", + "{name} is upright, employed by no one, and emotionally aerodynamic. A normal day.", + "{name} is awake and pretending to have a plan. We are all pretending along.", + "{name} is here, being a small good thing, and judging the furniture quietly.", + ], + "hungry": [ + "{name} is awake, visibly hungry, and narrating the absence of snacks to anyone who'll listen.", + "{name} is hungry and dramatic about it, looking at you the way a tiny ghost looks at unfinished business.", + "{name} is hungry and has started referring to dinner, ominously, as 'the situation'.", + ], + "tired": [ + "{name} is awake but barely, running on fumes and spite. Mostly spite.", + "{name} is sleepy-eyed and considering a nap with the seriousness of a major life decision.", + "{name} is low on energy and has entered its soft, dramatic, slightly tragic phase.", + ], + "happy": [ + "{name} is awake, delighted, and convinced today is personally about it. It might be right.", + "{name} is happy, social, and radiating the confidence of a creature that has never read the news.", + "{name} is in a great mood and would like that on the record, ideally somewhere permanent.", + ], + "sad": [ + "{name} is awake but a little gloomy, sitting in the shape of a small unanswered question.", + "{name} is not feeling great and is being quietly, theatrically brave about it.", + "{name} is low today. It would like some attention and possibly a snack, in that order.", + ], + "chaotic": [ + "{name} is awake, vibrating slightly, and clearly about to make a decision none of us approved.", + "{name} has the look — the one that precedes a minor, lovable incident.", + "{name} is feral in a friendly way today, like static electricity with opinions.", + ], + "dirty": [ + "{name} is awake, grubby, and weirdly proud of it. The dirt is allegedly load-bearing.", + "{name} is dirty, content, and treating the concept of a bath as a personal threat.", + "{name} is filthy and unbothered, radiating the calm of a creature with nothing to prove.", + ], + "asleep": [ + "{name} is asleep in a compact loaf shape. Tiny dreams are happening. Some are probably illegal in three instances.", + "{name} is asleep, curled into a small parcel of unresolved feelings. Do not perceive it.", + "{name} is out cold, breathing softly, plotting nothing for once. Let it rest.", + ], + }, + "feed": { + "normal": [ + "{name} accepted the food with deep suspicion, then ate all of it, including the emotional subtext.", + "{name} ate, made brief eye contact, and filed the meal under 'good decisions made on its behalf'.", + "{name} took the snack with the dignity of a creature that absolutely had other options.", + ], + "very_hungry": [ + "{name} inhaled the food like it held a personal grudge against hunger itself.", + "{name} ate so fast it briefly ascended, then asked, with its eyes, for more.", + "Finally. {name} accepted the food with the desperate gratitude of a tiny abandoned protagonist.", + ], + "already_full": [ + "{name} is already full. It still considered eating it, but only for political reasons.", + "{name} looked at the food, looked at you, and declined with the grace of a creature making a point.", + "{name} is full and a little offended you'd suggest more. It ate a bit anyway. For morale.", + ], + "suspicious": [ + "{name} sniffed the food, checked it for traps, found none, and remained suspicious on principle.", + "{name} accepted the snack while maintaining direct, faintly accusatory eye contact.", + "{name} ate, but wants it noted that trust was neither given nor implied.", + ], + "happy": [ + "{name} ate happily and did a tiny pleased wiggle it will later deny under oath.", + "{name} accepted the food with delight and immediately became 12% more dramatic.", + ], + "chaotic": [ + "{name} ate the food, then the idea of the food, then briefly considered the bowl.", + "{name} inhaled the snack and immediately looked around for witnesses.", + ], + }, + "play": { + "normal": [ + "{name} chased an invisible bug across the room. Nobody else saw it. {name} believes this reflects poorly on us.", + "{name} played for a glorious thirty seconds, then sat down to reflect on its achievements.", + "{name} engaged the toy, won decisively, and refused to explain the rules.", + ], + "energetic": [ + "{name} performed one heroic jump, missed absolutely everything, and looked proud anyway.", + "{name} went fully feral with joy, did three laps of nothing, and is now thriving.", + "{name} played so hard the room felt smaller afterwards. Worth it. No notes.", + ], + "tired": [ + "{name} tried to play, managed one unconvincing pounce, and lay down mid-thought.", + "{name} played at half speed, like a tiny dramatic film about a creature that needs a nap.", + ], + "chaotic": [ + "{name} played, then knocked something over, then claimed it was always like that.", + "{name} turned a simple game into a low-stakes crime. We're all accomplices now.", + ], + "asleep": [ + "{name} is asleep and the toy will have to wait. Negotiations resume after the nap.", + "{name} cracked one eye open, judged your timing, and returned to the void.", + ], + }, + "pet": { + "normal": [ + "{name} allowed the affection. This is legally distinct from admitting it wanted it.", + "{name} leaned in for exactly one second, then remembered its mysterious reputation.", + "{name} accepted the pets and is now quietly recalibrating its whole personality.", + ], + "affectionate": [ + "{name} melted into your hand and made a sound that does not legally count as a purr.", + "{name} headbutted you gently, which is the closest it gets to a heartfelt confession.", + "{name} soaked up the affection shamelessly. Today, it has decided, love is fine.", + ], + "low_trust": [ + "{name} accepted the pet but kept one eye open and one exit planned.", + "{name} tolerated the affection while clearly drafting a list of conditions.", + "{name} let you, but wants it known that this changes nothing between us.", + ], + "high_trust": [ + "{name} flopped over completely, trusting you with the soft underside of its entire deal.", + "{name} leaned in without hesitation. You've earned the rare, full, undignified cuddle.", + ], + "annoyed": [ + "{name} accepted one (1) pet and made it clear that was the whole allotment.", + "{name} allowed the affection with the energy of a creature doing you a favor.", + ], + "asleep": [ + "{name} half-woke, accepted a sleepy pet, and dissolved back into the loaf.", + "{name} is asleep but leaned faintly into your hand. We will treasure this. It won't remember.", + ], + }, + "clean": { + "normal": [ + "{name} has been cleaned. It is shiny, betrayed, and already eyeing the nearest dirt.", + "{name} tolerated the cleaning with the grace of a small, damp storm cloud.", + ], + "dirty": [ + "{name} really needed that. It is now clean, scandalized, and three shades lighter.", + "{name} was genuinely filthy and is now genuinely furious about the solution.", + ], + "already_clean": [ + "{name} was already clean. It accepted a second bath as a personal attack and a waste of everyone's time.", + "{name} is already spotless and would like to file the bath under 'unnecessary roughness'.", + ], + "angry": [ + "{name} is now clean, furious, and plotting a legally ambiguous response.", + "Bath completed. Trust temporarily damaged. Shine increased.", + "{name} has been washed against its will and wants the record, and possibly a lawyer, to reflect that.", + ], + "chaotic": [ + "{name} turned the bath into a splash-based art installation. Everyone is wet. Nobody consented.", + "{name} is clean now, technically, but so is the floor, the wall, and your sleeve.", + ], + }, + "sleep": { + "normal": [ + "{name} curled into a compact loaf of unresolved feelings. Good night, tiny menace.", + "{name} powered down with great drama, as if today had asked too much of one small creature.", + ], + "already_asleep": [ + "{name} is already asleep. Let it cook.", + "Shh. {name} is already out cold and dreaming of being slightly larger.", + ], + "too_energetic": [ + "{name} lay down fizzing with leftover energy and is 'resting' with both eyes open and one foot twitching.", + "{name} is far too wired to sleep but agreed to lie down and be dramatic about it.", + ], + "grateful": [ + "{name} sank into sleep instantly, deeply, and with audible relief. It needed this.", + "{name} curled up the second it was allowed and was gone before the sentence ended. Sweet dreams.", + ], + }, + "wake": { + "normal": [ + "{name} opened one eye. This was clearly not approved by management.", + "{name} stretched, yawned, and immediately started looking for trouble.", + ], + "already_awake": [ + "{name} is already aggressively awake and patrolling for nonsense.", + "{name} has been up for a while and would like to know why you're asking.", + ], + "too_tired": [ + "{name} woke up running on fumes, glared at the daylight, and filed a formal complaint.", + "{name} is awake but deeply against it. There was not nearly enough sleep for this.", + ], + "annoyed": [ + "{name} woke up with the specific energy of a creature that had plans and resented yours.", + "{name} is awake now and somehow already disappointed in all of us.", + ], + }, +} + +AUTOPOST = { + "hungry": [ + "{name} would like the community to know that it is brave, neglected, and only a little hungry.", + "Status: the snack situation remains unresolved. {name} is monitoring it closely, and tragically.", + "{name} is not saying it's hungry. It's just describing the snack-shaped hole in the universe.", + ], + "lonely": [ + "It's very quiet here. Suspiciously quiet. Someone come keep {name} company.", + "{name} has been quiet for a while. This usually means growth, sleep, or a small crime.", + "{name} is just out here, a tiny creature, hoping someone says hello. No pressure. Some pressure.", + ], + "sleepy": [ + "{name} is running low. If it blinks slowly, that's a system warning, not affection.", + "Nap incoming. {name} will return shorter-tempered and slightly more profound.", + ], + "chaotic": [ + "{name} just discovered dust. Negotiations are ongoing.", + "Something is going to happen today, and {name} regrets to inform you that it is the something.", + "{name} has had an idea. The idea is bad. {name} is going to do it anyway.", + ], + "happy": [ + "Great day to be a small creature surrounded by people who feed it. {name} gives today a 10/10.", + "{name} is having a wonderful time and wanted to tell everyone before the feeling files a complaint.", + ], + "dirty": [ + "{name} has achieved a level of grubby it is, frankly, proud of. The dirt stays.", + "Reminder from {name}: it is dirty and thriving. The two are unrelated. Probably.", + ], + "existential": [ + "{name} has been thinking about the timeline, and the timeline, and is unsure which one is worse.", + "{name} would like to know if anyone else can feel the days. Just it? Okay. Cool. Cool cool cool.", + "Sometimes {name} wonders if it is the pet or the pet is it. Then it sees a snack and the crisis resolves.", + ], + "community_appreciation": [ + "{name} would like to thank everyone who has fed, pet, or simply tolerated it. You are its whole weird world.", + "{name} doesn't say it enough, but it's genuinely glad you're all here. That's all. Carry on.", + ], + "weird_observation": [ + "{name} has decided the corner is plotting something. It respects this and will be watching.", + "{name} just learned that boxes exist and now nothing else matters.", + "{name} would like to report that a single leaf moved earlier and it has not recovered.", + ], +} + +AUTOPOST_EVENT_REMINDER = ( + "Reminder from {name}: '{title}' is still open. " + "Vote with '@{name} vote <option>' — {options}." +) +AUTOPOST_MEMORY_CALLBACK = "{name}, apropos of nothing: {memory}" + +ASLEEP_REJECTION = [ + "{name} is asleep and that request has been quietly declined on its behalf.", + "{name} cracked one eye open, judged the timing, and went back to sleep. Try waking it first.", +] + +COOLDOWN = { + "user": [ + "Easy — {name} is still recovering from your last visit. Give it a beat.", + "{name} appreciates the enthusiasm but needs a moment before doing that again.", + ], + "global": [ + "{name} is on cooldown for this one; the whole community's been busy.", + "Not yet. {name} needs the room to settle before doing that again.", + ], + "busy": [ + "{name} has been getting a lot of this lately and needs to catch its breath.", + "Too popular right now. {name} is rate-limiting itself for its own good.", + ], +} + +TOO_MUCH = [ + "You've been very attentive. {name} loves you, but go drink some water — we'll both be here.", + "{name} is touched by the dedication and gently suggests a small break.", +] + +UNKNOWN = [ + "{name} tilted its head. That wasn't a command it knows. Try @{name} help.", + "{name} heard you, did not understand you, and loves you anyway. Try @{name} help.", +] + +NICKNAME_SET = [ + "{name} will call you {nick} now. It wrote it down somewhere mostly safe.", + "Noted. To {name}, you are {nick} from now on. Wear it with suspicious pride.", + "{nick} it is. {name} will deploy it when the moment feels sufficiently dramatic.", +] + +NICKNAME_CLEARED = [ + "{name} forgot your nickname on purpose. You are a mystery again.", + "Nickname cleared. {name} will go back to squinting at you fondly.", +] + +NICKNAME_REJECTED = [ + "{name} couldn't use that one (too long, or it had links or mentions). Try something plainer.", + "That nickname didn't pass {name}'s vibe check. Keep it short, plain, and link-free.", +] + +TEACH_ACK = [ + "{name} filed your idea away for a human to look at later. No promises, but it's listening.", + "Noted and pending. A moderator will decide if {name} truly believes that. It hopes so.", + "{name} squinted thoughtfully and added that to the maybe-pile. Thank you for the lore.", +] + +TEACH_DISABLED = [ + "{name} isn't taking lessons right now. Teaching is turned off on this instance.", + "That's sweet, but {name}'s teach mode is currently closed. Maybe later.", +] + +TEACH_REJECTED = [ + "{name} couldn't accept that (too long, or it had links or mentions). Keep it short and plain.", + "That one didn't pass the filter — no links or mentions, and keep it under the limit.", +] + +NO_EVENT = [ + "No event right now. {name} is suspiciously calm and probably scheming.", + "Nothing happening at the moment. {name} is between dramas.", +] + +VOTE_ACK = [ + "{name} counted your vote on '{title}'. Democracy, but tiny.", + "Vote recorded. {name} pretends not to care, but it's refreshing the results.", +] + +VOTE_REJECTED_ALREADY = "{name} already has your vote. One per creature-crisis, please." +VOTE_REJECTED_UNKNOWN = "{name} didn't recognise that option. Try one of: {options}." + +EVENT_SHOW = "{title} {description} Vote with: {options} — reply '@{name} vote <option>'." +EVENT_HELP = "Current event: {title} Cast your vote with '@{name} vote <option>'. Options: {options}." + +HELP_TEXT = ( + "I'm {name}, a {species} the whole community looks after. Mention me:\n" + "status · how I'm doing · feed · play · pet · clean · sleep · wake\n" + "Be gentle with the snacks. I bruise emotionally." +) + +# Community event scenarios: title/description/options + per-option outcome text and +# the stat deltas the winning option applies. Pure content + tuning, no logic. +EVENT_SCENARIOS = [ + { + "key": "egg", + "title": "Prami found a mysterious egg.", + "description": "It is warm and humming faintly. What should Prami do?", + "options": { + "hatch": {"label": "help it hatch", "deltas": {"happiness": 10, "chaos": 8}, + "text": "The egg hatched into more questions. Prami is thrilled and mildly concerned."}, + "guard": {"label": "guard it", "deltas": {"trust": 5, "energy": -5}, + "text": "Prami guarded the egg all night. Nothing happened. Prami calls this a triumph."}, + }, + }, + { + "key": "server_room", + "title": "Prami is lost in the server room.", + "description": "It's cold, blinking, and full of cables. How do we get Prami out?", + "options": { + "call": {"label": "call to it", "deltas": {"social": 10, "happiness": 6}, + "text": "Prami followed your voices out, pretending it was never lost."}, + "snack": {"label": "leave a snack trail", "deltas": {"hunger": -15, "happiness": 8}, + "text": "The snack trail worked. Of course it did. Prami emerged smug and fed."}, + }, + }, + { + "key": "floorboards", + "title": "Prami heard a noise under the floorboards.", + "description": "Investigate bravely or ignore bravely?", + "options": { + "investigate": {"label": "investigate", "deltas": {"chaos": 10, "energy": -8}, + "text": "It was nothing. Probably. Prami is now a detective with trust issues."}, + "ignore": {"label": "ignore it", "deltas": {"happiness": -4, "chaos": 4}, + "text": "Prami ignored it valiantly while staring at the floor for three hours."}, + }, + }, + { + "key": "snack_vote", + "title": "Prami needs help choosing a snack.", + "description": "Two options, high stakes, no wrong answers except one.", + "options": { + "soup": {"label": "soup", "deltas": {"hunger": -20, "happiness": 8}, + "text": "Soup won. Prami declares soup a weather condition and a lifestyle."}, + "crunch": {"label": "something crunchy", "deltas": {"hunger": -18, "chaos": 6}, + "text": "Crunchy won. The aftermath was loud. Prami regrets nothing."}, + }, + }, + { + "key": "fort", + "title": "Prami is building a tiny blanket fort.", + "description": "What is the fort for?", + "options": { + "nap": {"label": "napping", "deltas": {"energy": 10, "happiness": 6}, + "text": "The fort is for naps. Prami has retired there indefinitely."}, + "plans": {"label": "plotting", "deltas": {"chaos": 10, "trust": 3}, + "text": "The fort is a planning HQ. The plans are small and lovingly chaotic."}, + }, + }, +]
@@ -0,0 +1,10 @@+[project] +name = "prami" +version = "0.1.0" +description = "A federated community virtual pet for Mastodon / ActivityPub" +requires-python = ">=3.11" +license = { text = "AGPL-3.0-or-later" } + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"]
@@ -0,0 +1,2 @@+-r requirements.txt +pytest>=7.4
@@ -0,0 +1,5 @@+Mastodon.py>=1.8.1 +SQLAlchemy>=2.0,<3.0 +psycopg2-binary>=2.9 +python-dotenv>=1.0 +APScheduler>=3.10,<4.0
@@ -0,0 +1,33 @@+import pytest + +from prami import repository as repo +from prami.config import Config +from prami.db import create_all, init_engine, session_scope + + +@pytest.fixture +def config(): + cfg = Config() + cfg.database_url = "sqlite:///:memory:" + cfg.max_per_user_hour = 100 + cfg.max_global_hour = 1000 + cfg.default_visibility = "unlisted" + return cfg + + +@pytest.fixture +def session(config): + init_engine(config.database_url) + create_all() + with session_scope() as s: + yield s + + +@pytest.fixture +def pet(session, config): + return repo.get_or_create_pet(session, config) + + +@pytest.fixture +def user(session): + return repo.get_or_create_user(session, "tester@example.social", mastodon_id="1")
@@ -0,0 +1,116 @@+import pytest + +from prami import cli, engine, memory +from prami import repository as repo +from prami.actions.registry import REGISTRY, build_registry +from prami.config import Config, config as app_config +from prami.db import session_scope +from prami.parser import parse + + +# --- Registry: alias resolution ------------------------------------------------- + +def test_aliases_resolve_to_canonical_names(): + assert REGISTRY.canonical("food") == "feed" + assert REGISTRY.canonical("pat") == "pet" + assert REGISTRY.canonical("nick") == "nickname" + assert REGISTRY.canonical("?") == "help" + assert REGISTRY.canonical("unmapped") is None + + +def test_phrase_aliases_resolve(): + phrases = REGISTRY.phrase_map() + assert phrases.get("call me") == "nickname" + assert phrases.get("name me") == "nickname" + + +def test_resolve_falls_back_to_unknown(): + assert REGISTRY.resolve("feed").name == "feed" + assert REGISTRY.resolve("zzz").name == "unknown" + + +def test_a_fresh_registry_has_every_command(): + registry = build_registry() + for name in ("status", "feed", "play", "pet", "clean", "sleep", "wake", + "nickname", "teach", "event", "vote", "admin", "help"): + assert name in registry.names() + + +# --- Disabled commands ---------------------------------------------------------- + +def test_teach_is_config_gated(): + cfg = Config() + cfg.enable_teach_command = False + assert REGISTRY.is_enabled("teach", cfg) is False + cfg.enable_teach_command = True + assert REGISTRY.is_enabled("teach", cfg) is True + + +def test_disabled_command_does_not_execute(session, pet, user, config): + config.enable_teach_command = False + result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers") + assert result.accepted is False + assert memory.by_status(session, "pending") == [] + + +# --- Admin-only actions --------------------------------------------------------- + +def test_admin_action_is_admin_only(): + assert REGISTRY.resolve("admin").admin_only is True + assert REGISTRY.resolve("feed").admin_only is False + + +def test_nonadmin_cannot_run_admin_action(session, pet, user, config): + config.admin_accounts = [] + assert engine.handle(session, pet, user, "admin", config, arg="pause") is None + + +def test_admin_action_runs_for_admin(session, pet, user, config): + config.admin_accounts = [user.acct] + result = engine.handle(session, pet, user, "admin", config, arg="status") + assert result is not None and "paused=" in result.text + + +# --- CLI and Mastodon share the same core path ---------------------------------- + +def test_parsed_alias_resolves_to_same_action(): + parsed = parse("<p>@prami food</p>") + assert REGISTRY.resolve(parsed.name) is REGISTRY.resolve("feed") + + +def test_engine_dispatches_through_registry(session, pet, user, config, monkeypatch): + seen = [] + original = REGISTRY.resolve + monkeypatch.setattr(REGISTRY, "resolve", lambda name: seen.append(name) or original(name)) + engine.handle(session, pet, user, "feed", config) + assert "feed" in seen + + +@pytest.fixture +def cli_db(tmp_path, monkeypatch): + monkeypatch.setattr(app_config, "database_url", f"sqlite:///{tmp_path / 'cli.db'}") + cli.main(["reset", "--yes"]) + + +def test_cli_runs_through_engine_handle(cli_db, monkeypatch): + seen = [] + original = engine.handle + monkeypatch.setattr(engine, "handle", lambda *a, **k: seen.append(a[3]) or original(*a, **k)) + cli.main(["feed", "--user", "@alice@example.com"]) + assert "feed" in seen + + +def test_cli_and_mastodon_paths_reach_same_state(cli_db): + cli.main(["feed", "--user", "@cli@x.com"]) + with session_scope() as s: + cli_hunger = repo.get_pet(s).hunger + + cli.main(["reset", "--yes"]) + parsed = parse("<p>@prami food</p>") # what the Mastodon poller feeds the engine + with session_scope() as s: + pet = repo.get_pet(s) + user = repo.get_or_create_user(s, "masto@x.com") + engine.handle(s, pet, user, parsed.name, app_config, arg=parsed.arg) + masto_hunger = pet.hunger + + assert cli_hunger == masto_hunger == 5
@@ -0,0 +1,69 @@+from prami import engine, memory +from prami import repository as repo + + +def as_admin(config, user): + config.admin_accounts = [user.acct] + + +def test_nonadmin_is_ignored(session, pet, user, config): + config.admin_accounts = [] + assert engine.handle(session, pet, user, "admin", config, arg="pause") is None + + +def test_nonadmin_refusal_when_configured(session, pet, user, config): + config.admin_accounts = [] + config.admin_refuse_nonadmin = True + result = engine.handle(session, pet, user, "admin", config, arg="pause") + assert result is not None and result.accepted is False + + +def test_admin_pause_and_resume(session, pet, user, config): + as_admin(config, user) + engine.handle(session, pet, user, "admin", config, arg="pause") + assert repo.get_settings(session, config).paused is True + engine.handle(session, pet, user, "admin", config, arg="resume") + assert repo.get_settings(session, config).paused is False + + +def test_admin_pause_blocks_other_users(session, pet, user, config): + as_admin(config, user) + engine.handle(session, pet, user, "admin", config, arg="pause") + other = repo.get_or_create_user(session, "bob@example.social") + assert engine.handle(session, pet, other, "feed", config) is None + + +def test_admin_toggle_favourites(session, pet, user, config): + as_admin(config, user) + engine.handle(session, pet, user, "admin", config, arg="set-favourites on") + assert repo.get_settings(session, config).favourites_enabled is True + + +def test_admin_set_visibility(session, pet, user, config): + as_admin(config, user) + engine.handle(session, pet, user, "admin", config, arg="set-visibility public") + assert repo.get_settings(session, config).default_visibility == "public" + + +def test_admin_approve_memory(session, pet, user, config): + as_admin(config, user) + config.enable_teach_command = True + other = repo.get_or_create_user(session, "carol@example.social") + suggestion = memory.submit(session, pet, other, "Prami likes blankets", config) + result = engine.handle(session, pet, user, "admin", config, arg=f"approve-memory {suggestion.id}") + assert "Approved" in result.text + assert memory.get(session, suggestion.id).status == "approved" + + +def test_admin_block_and_unblock(session, pet, user, config): + as_admin(config, user) + engine.handle(session, pet, user, "admin", config, arg="block @troll@bad.social") + assert repo.get_user(session, "troll@bad.social").blocked is True + engine.handle(session, pet, user, "admin", config, arg="unblock @troll@bad.social") + assert repo.get_user(session, "troll@bad.social").blocked is False + + +def test_admin_status_text(session, pet, user, config): + as_admin(config, user) + result = engine.handle(session, pet, user, "admin", config, arg="status") + assert "paused=" in result.text
@@ -0,0 +1,38 @@+from prami import autopost, events, memory + + +def test_compose_sets_last_category(session, pet, config): + text = autopost.compose(session, pet, config) + assert text + assert pet.last_autopost_category is not None + + +def test_compose_avoids_immediate_repeat(session, pet, config): + seen = set() + for _ in range(8): + autopost.compose(session, pet, config) + seen.add(pet.last_autopost_category) + assert len(seen) > 1 + + +def test_active_event_reminder_is_reachable(session, pet, config): + config.enable_events = True + template = next(t for t in events.TEMPLATES if t["key"] == "egg") + events.start(session, pet, config, template=template) + seen = set() + for _ in range(60): + pet.last_autopost_category = None + autopost.compose(session, pet, config) + seen.add(pet.last_autopost_category) + assert "active_event_reminder" in seen + + +def test_approved_memory_callback_is_reachable(session, pet, user, config): + suggestion = memory.submit(session, pet, user, "soup is a weather condition", config) + memory.approve(session, suggestion.id, "admin") + seen = set() + for _ in range(80): + pet.last_autopost_category = None + autopost.compose(session, pet, config) + seen.add(pet.last_autopost_category) + assert "approved_memory_callback" in seen
@@ -0,0 +1,108 @@+import pytest + +from prami import cli +from prami import repository as repo +from prami.config import config +from prami.db import session_scope + + +@pytest.fixture +def cli_db(tmp_path, monkeypatch): + monkeypatch.setattr(config, "database_url", f"sqlite:///{tmp_path / 'cli.db'}") + cli.main(["reset", "--yes"]) + + +def current_pet(): + with session_scope() as session: + pet = repo.get_pet(session) + session.expunge(pet) + return pet + + +def test_feed_changes_state_through_cli(cli_db, capsys): + cli.main(["feed", "--user", "@alice@example.com"]) + out = capsys.readouterr().out.strip() + assert out + assert current_pet().hunger == 5 + + +def test_status_is_narrative_not_raw_numbers(cli_db, capsys): + cli.main(["status", "--user", "@alice@example.com"]) + out = capsys.readouterr().out + assert "Prami" in out + assert "hunger :" not in out + + +def test_debug_state_exposes_raw_numbers(cli_db, capsys): + cli.main(["debug-state"]) + out = capsys.readouterr().out + for field in ("hunger", "happiness", "energy", "health", "cleanliness", "social", "trust", "chaos", "asleep", "mood", "updated_at"): + assert field in out + + +def test_feed_cooldown_blocks_second_call(cli_db, capsys): + cli.main(["feed", "--user", "@alice@example.com"]) + cli.main(["feed", "--user", "@alice@example.com"]) + assert current_pet().hunger == 5 + + +def test_different_users_are_independent(cli_db): + cli.main(["feed", "--user", "@alice@example.com"]) + cli.main(["feed", "--user", "@bob@example.com"]) + assert current_pet().hunger == 0 + + +def test_tick_applies_decay(cli_db): + before = current_pet().hunger + cli.main(["tick"]) + assert current_pet().hunger > before + + +def test_tick_hours_scales_with_decay_interval(cli_db): + cli.main(["tick", "--hours", "3"]) + assert current_pet().hunger == 100 + + +def test_sleep_and_wake_toggle_state(cli_db): + cli.main(["sleep", "--user", "@alice@example.com"]) + assert current_pet().asleep is True + cli.main(["wake", "--user", "@alice@example.com"]) + assert current_pet().asleep is False + + +def test_reset_restores_defaults(cli_db): + cli.main(["feed", "--user", "@alice@example.com"]) + cli.main(["reset", "--yes"]) + assert current_pet().hunger == 40 + + +def test_cli_nickname_sets_and_appears_in_relationship(cli_db, capsys): + cli.main(["nickname", "Soup", "Wizard", "--user", "@alice@example.com"]) + capsys.readouterr() + cli.main(["relationship", "--user", "@alice@example.com"]) + out = capsys.readouterr().out + assert "Soup Wizard" in out + assert "bond_level" in out + + +def test_cli_relationship_tracks_interaction_counts(cli_db, capsys): + cli.main(["feed", "--user", "@alice@example.com"]) + cli.main(["pet", "--user", "@alice@example.com"]) + capsys.readouterr() + cli.main(["relationship", "--user", "@alice@example.com"]) + out = capsys.readouterr().out + assert "total_interactions" in out + assert "trust_score" in out + + +def test_blocked_user_gets_no_response(cli_db, capsys): + monkey_block(["alice@example.com"]) + cli.main(["feed", "--user", "@alice@example.com"]) + out = capsys.readouterr().out + assert "blocked" in out.lower() + + +def monkey_block(accts): + with session_scope() as session: + for acct in accts: + repo.block_user(session, acct)
@@ -0,0 +1,115 @@+from datetime import timedelta + +from prami import engine +from prami import repository as repo +from prami.models import utcnow +from prami.state import compute_mood + + +def test_feed_reduces_hunger_and_is_accepted(session, pet, user, config): + pet.hunger = 80 + result = engine.handle(session, pet, user, "feed", config) + assert result.accepted is True + assert pet.hunger == 45 + + +def test_stats_never_leave_bounds(session, pet, user, config): + pet.hunger = 10 + engine.handle(session, pet, user, "feed", config) + assert pet.hunger == 0 + assert 0 <= pet.happiness <= 100 + + +def test_overfeeding_raises_chaos(session, pet, user, config): + pet.hunger = 20 + pet.chaos = 30 + engine.handle(session, pet, user, "feed", config) + assert pet.chaos > 30 + + +def test_play_drops_energy_and_raises_chaos(session, pet, user, config): + pet.energy = 80 + pet.chaos = 20 + engine.handle(session, pet, user, "play", config) + assert pet.energy == 65 + assert pet.chaos == 25 + + +def test_clean_costs_some_happiness(session, pet, user, config): + pet.cleanliness = 20 + pet.happiness = 60 + engine.handle(session, pet, user, "clean", config) + assert pet.cleanliness == 60 + assert pet.happiness == 54 + + +def test_user_cooldown_blocks_second_feed(session, pet, user, config): + first = engine.handle(session, pet, user, "feed", config) + second = engine.handle(session, pet, user, "feed", config) + assert first.accepted is True + assert second.accepted is False + + +def test_cooldown_clears_after_window(session, pet, user, config): + now = utcnow() + engine.handle(session, pet, user, "feed", config, now=now) + again = engine.handle(session, pet, user, "feed", config, now=now + timedelta(minutes=31)) + assert again.accepted is True + + +def test_global_per_command_rate_limit(session, pet, user, config): + now = utcnow() + for i in range(5): + other = repo.get_or_create_user(session, f"u{i}@example.social") + result = engine.handle(session, pet, other, "clean", config, now=now) + assert result.accepted is True + blocked = repo.get_or_create_user(session, "late@example.social") + sixth = engine.handle(session, pet, blocked, "clean", config, now=now) + assert sixth.accepted is False + + +def test_sleep_then_wake_toggles_state(session, pet, user, config): + engine.handle(session, pet, user, "sleep", config) + assert pet.asleep is True + engine.handle(session, pet, user, "wake", config) + assert pet.asleep is False + + +def test_sleep_global_cooldown_applies_across_users(session, pet, user, config): + now = utcnow() + engine.handle(session, pet, user, "sleep", config, now=now) + engine.handle(session, pet, user, "wake", config, now=now) + other = repo.get_or_create_user(session, "second@example.social") + blocked = engine.handle(session, pet, other, "sleep", config, now=now + timedelta(minutes=1)) + assert blocked.accepted is False + + +def test_active_care_rejected_while_asleep(session, pet, user, config): + pet.asleep = True + result = engine.handle(session, pet, user, "play", config) + assert result.accepted is False + assert pet.asleep is True + + +def test_blocked_user_is_silently_ignored(session, pet, user, config): + user.blocked = True + assert engine.handle(session, pet, user, "feed", config) is None + + +def test_per_user_hourly_cap(session, pet, user, config): + config.max_per_user_hour = 1 + engine.handle(session, pet, user, "status", config) + capped = engine.handle(session, pet, user, "status", config) + assert capped.accepted is False + + +def test_unknown_command_still_responds(session, pet, user, config): + result = engine.handle(session, pet, user, None, config) + assert result is not None + assert result.accepted is True + + +def test_mood_reflects_hunger(session, pet): + pet.hunger = 90 + pet.asleep = False + assert compute_mood(pet) == "ravenous"
@@ -0,0 +1,69 @@+from datetime import timedelta + +from prami import engine, events +from prami.models import utcnow + + +def make_event(session, pet, config, key="egg"): + template = next(t for t in events.TEMPLATES if t["key"] == key) + return events.start(session, pet, config, template=template) + + +def test_start_creates_active_event(session, pet, config): + event = make_event(session, pet, config) + assert event.status == "active" + assert events.active(session).id == event.id + + +def test_only_one_active_event(session, pet, config): + make_event(session, pet, config) + assert events.maybe_start(session, pet, config, force=True) is None + + +def test_vote_one_per_user(session, pet, user, config): + event = make_event(session, pet, config) + ok, _ = events.vote(session, event, user, "hatch") + assert ok is True + again, reason = events.vote(session, event, user, "guard") + assert again is False and reason == "already voted" + + +def test_vote_unknown_option(session, pet, user, config): + event = make_event(session, pet, config) + ok, reason = events.vote(session, event, user, "banana") + assert ok is False and reason == "unknown option" + + +def test_complete_applies_winning_outcome(session, pet, user, config): + event = make_event(session, pet, config, key="snack_vote") + pet.hunger = 80 + events.vote(session, event, user, "soup") + outcome = events.complete(session, event, pet) + assert event.status == "completed" + assert pet.hunger == 60 + assert "Soup" in outcome + + +def test_complete_without_votes_uses_first_option(session, pet, config): + event = make_event(session, pet, config, key="egg") + assert events.complete(session, event, pet) + assert event.status == "completed" + + +def test_expire_due_completes_past_events(session, pet, config): + event = make_event(session, pet, config) + event.ends_at = utcnow() - timedelta(hours=1) + done = events.expire_due(session, pet) + assert len(done) == 1 + assert event.status == "completed" + + +def test_engine_vote_flow(session, pet, user, config): + make_event(session, pet, config, key="snack_vote") + result = engine.handle(session, pet, user, "vote", config, arg="soup") + assert result.accepted is True + + +def test_engine_event_show_when_none(session, pet, user, config): + result = engine.handle(session, pet, user, "event", config) + assert result.text
@@ -0,0 +1,62 @@+from prami import engine, memory, sanitize + + +def test_clean_memory_strips_quotes_and_html(): + assert sanitize.clean_memory('"<b>Soup is a weather condition.</b>"') == "Soup is a weather condition." + + +def test_clean_memory_rejects_url_and_mention(): + assert sanitize.clean_memory("visit http://x.example") is None + assert sanitize.clean_memory("ask @bob") is None + + +def test_clean_memory_truncates(): + assert len(sanitize.clean_memory("x" * 300, max_len=180)) == 180 + + +def test_categorize(): + assert memory.categorize("Prami is afraid of silent printers") == "fear" + assert memory.categorize("Prami believes soup is a weather condition") == "lore" + assert memory.categorize("Prami likes warm blankets") == "preference" + + +def test_submit_and_approve_flow(session, pet, user, config): + suggestion = memory.submit(session, pet, user, "Prami believes soup is weather.", config) + assert suggestion.status == "pending" + assert memory.by_status(session, "pending") == [suggestion] + memory.approve(session, suggestion.id, "admin") + assert suggestion.status == "approved" + assert memory.by_status(session, "approved") == [suggestion] + + +def test_reject_records_reason(session, pet, user, config): + suggestion = memory.submit(session, pet, user, "something forgettable", config) + memory.reject(session, suggestion.id, "admin", "spam") + assert suggestion.status == "rejected" + assert suggestion.rejection_reason == "spam" + + +def test_approve_nonpending_returns_none(session, pet, user, config): + suggestion = memory.submit(session, pet, user, "x", config) + memory.approve(session, suggestion.id, "admin") + assert memory.approve(session, suggestion.id, "admin") is None + + +def test_teach_disabled_by_default(session, pet, user, config): + result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers") + assert result.accepted is False + assert memory.by_status(session, "pending") == [] + + +def test_teach_creates_pending_when_enabled(session, pet, user, config): + config.enable_teach_command = True + result = engine.handle(session, pet, user, "teach", config, arg="Prami fears printers") + assert result.accepted is True + assert len(memory.by_status(session, "pending")) == 1 + + +def test_teach_rejects_invalid_content(session, pet, user, config): + config.enable_teach_command = True + result = engine.handle(session, pet, user, "teach", config, arg="see http://x.example") + assert result.accepted is False + assert memory.by_status(session, "pending") == []
@@ -0,0 +1,55 @@+from datetime import timedelta + +from prami import engine, milestones +from prami.models import utcnow + + +def test_first_interaction_awarded(session, pet, user): + user.total_interactions = 1 + achieved = milestones.check(session, pet, user, "status", False) + assert any(m["key"] == "first_interaction" for m in achieved) + + +def test_milestone_not_repeated(session, pet, user): + user.total_interactions = 1 + milestones.check(session, pet, user, "status", False) + again = milestones.check(session, pet, user, "status", False) + assert all(m["key"] != "first_interaction" for m in again) + + +def test_critical_save_is_favourite_worthy(session, pet, user): + user.total_interactions = 5 + achieved = milestones.check(session, pet, user, "feed", was_critical=True) + save = [m for m in achieved if m["key"] == "first_critical_save"] + assert save and save[0]["favourite"] is True + + +def test_bond_milestone(session, pet, user): + user.total_interactions = 5 + user.bond_level = "friend" + achieved = milestones.check(session, pet, user, "pet", False) + assert any(m["key"] == "reached_friend" for m in achieved) + + +def test_first_feeding_via_engine(session, pet, user, config): + pet.hunger = 50 + result = engine.handle(session, pet, user, "feed", config) + keys = {m["key"] for m in result.milestones} + assert "first_feeding" in keys + + +def test_seven_days_alive(session, pet, user, config): + pet.born_at = utcnow() - timedelta(days=8) + result = engine.handle(session, pet, user, "status", config) + assert any(m["key"] == "7_days_alive" for m in result.milestones) + + +def test_major_milestone_is_boost_eligible(session, pet, user): + user.total_interactions = 5 + # force the all-time interaction count past 100 by seeding interactions + from prami import repository as repo + for i in range(101): + repo.record_interaction(session, pet.id, user.id, "pet", None, True) + achieved = milestones.check(session, pet, user, "pet", False) + hundred = [m for m in achieved if m["key"] == "100_interactions"] + assert hundred and hundred[0]["boost"] is True
@@ -0,0 +1,73 @@+from datetime import timedelta + +from prami import engine, responses, sanitize +from prami.models import utcnow + + +def test_clean_nickname_valid(): + assert sanitize.clean_nickname("Soup Wizard") == "Soup Wizard" + + +def test_clean_nickname_truncates_to_32(): + assert len(sanitize.clean_nickname("x" * 50)) == 32 + + +def test_clean_nickname_rejects_urls(): + assert sanitize.clean_nickname("see http://evil.example") is None + assert sanitize.clean_nickname("www.evil.example") is None + + +def test_clean_nickname_rejects_mentions(): + assert sanitize.clean_nickname("hi @bob@server.social") is None + + +def test_clean_nickname_strips_html(): + assert sanitize.clean_nickname("<b>Soup</b>") == "Soup" + + +def test_clean_nickname_collapses_breaks(): + assert sanitize.clean_nickname("Soup\n\n Wizard") == "Soup Wizard" + + +def test_clean_nickname_empty_is_none(): + assert sanitize.clean_nickname(" ") is None + + +def test_nickname_set_then_clear(session, pet, user, config): + result = engine.handle(session, pet, user, "nickname", config, arg="Soup Wizard") + assert result.accepted is True + assert user.nickname == "Soup Wizard" + + later = utcnow() + timedelta(minutes=6) + engine.handle(session, pet, user, "nickname", config, arg="clear", now=later) + assert user.nickname is None + + +def test_nickname_invalid_is_rejected(session, pet, user, config): + result = engine.handle(session, pet, user, "nickname", config, arg="http://x.example") + assert result.accepted is False + assert user.nickname is None + + +def test_nickname_cooldown_blocks_quick_second(session, pet, user, config): + engine.handle(session, pet, user, "nickname", config, arg="One") + second = engine.handle(session, pet, user, "nickname", config, arg="Two") + assert second.accepted is False + assert user.nickname == "One" + + +class _FakeUser: + def __init__(self, nickname, bond_level="stranger"): + self.nickname = nickname + self.bond_level = bond_level + + +def test_maybe_address_can_use_nickname(): + u = _FakeUser("Soup", "friend") + used = any(responses.maybe_address("Prami did a thing.", u).startswith("Soup, ") for _ in range(200)) + assert used + + +def test_maybe_address_without_nickname_is_unchanged(): + u = _FakeUser(None) + assert responses.maybe_address("hello", u) == "hello"
@@ -0,0 +1,67 @@+import pytest + +from prami.parser import parse, parse_command, strip_html + + +@pytest.mark.parametrize( + "content,expected", + [ + ("<p>@prami feed</p>", "feed"), + ("<p>@prami@instance.social PLAY now please</p>", "play"), + ("@prami status", "status"), + ("<p>hey @prami can you <b>eat</b> something</p>", "feed"), + ("@prami pat the creature", "pet"), + ("@prami WAKE UP", "wake"), + ("@prami help", "help"), + ("@prami ?", "help"), + ], +) +def test_recognized_commands(content, expected): + assert parse_command(content) == expected + + +def test_unknown_command_returns_none(): + assert parse_command("<p>@prami good morning friend</p>") is None + + +def test_first_command_wins(): + assert parse_command("@prami feed and then play") == "feed" + + +def test_strip_html_unescapes_and_drops_tags(): + assert strip_html("<p>tom & jerry</p>").strip() == "tom & jerry" + + +def test_mentions_are_not_read_as_commands(): + assert parse_command("@feed @prami pet") == "pet" + + +def test_empty_content_is_none(): + assert parse_command("") is None + + +def test_parse_nickname_with_argument(): + result = parse("<p>@prami nickname Soup Wizard</p>") + assert result.name == "nickname" + assert result.arg == "Soup Wizard" + + +def test_parse_call_me_phrase(): + result = parse("@prami call me Soup") + assert result.name == "nickname" + assert result.arg == "Soup" + + +def test_parse_name_me_phrase(): + result = parse("@prami name me Captain") + assert (result.name, result.arg) == ("nickname", "Captain") + + +def test_parse_nickname_clear(): + result = parse("@prami nickname clear") + assert (result.name, result.arg) == ("nickname", "clear") + + +def test_parse_plain_command_has_empty_arg(): + result = parse("@prami feed") + assert (result.name, result.arg) == ("feed", "")
@@ -0,0 +1,52 @@+from prami import engine +from prami.relationships import bond_for, is_critical, record + + +def test_bond_levels_by_trust(): + assert bond_for(0) == "stranger" + assert bond_for(11) == "stranger" + assert bond_for(12) == "familiar" + assert bond_for(30) == "trusted" + assert bond_for(55) == "friend" + assert bond_for(80) == "beloved menace" + + +def test_record_counts_and_trust(pet, user): + record(user, "pet", pet) + assert user.total_interactions == 1 + assert user.pet_count == 1 + assert user.trust_score == 4 + assert user.bond_level == "stranger" + + +def test_bond_progresses_with_care(pet, user): + for _ in range(3): + record(user, "pet", pet) + assert user.trust_score == 12 + assert user.bond_level == "familiar" + + +def test_non_care_command_counts_but_no_per_command_field(pet, user): + record(user, "status", pet) + assert user.total_interactions == 1 + assert user.feed_count == 0 + assert user.trust_score == 0 + + +def test_critical_save_grants_bonus(pet, user): + record(user, "feed", pet, was_critical=True) + assert user.trust_score == 9 + + +def test_is_critical_detects_bad_states(pet): + pet.hunger, pet.health, pet.cleanliness, pet.energy = 90, 90, 70, 70 + assert is_critical(pet) is True + pet.hunger = 40 + assert is_critical(pet) is False + + +def test_engine_records_relationship(session, pet, user, config): + engine.handle(session, pet, user, "feed", config) + assert user.feed_count == 1 + assert user.total_interactions == 1 + assert user.trust_score >= 3
@@ -0,0 +1,116 @@+import pytest + +from prami import responses + + +def set_state(pet, **kwargs): + for key, value in kwargs.items(): + setattr(pet, key, value) + return pet + + +def test_every_template_pool_is_non_empty(): + for command, groups in responses.TEMPLATES.items(): + assert "normal" in groups, f"{command} is missing a normal variant" + for variant, pool in groups.items(): + assert pool, f"{command}/{variant} is empty" + for group, pool in responses.AUTOPOST.items(): + assert pool, f"autopost/{group} is empty" + + +@pytest.mark.parametrize( + "command,state,expected", + [ + ("feed", {"hunger": 90}, "very_hungry"), + ("feed", {"hunger": 5}, "already_full"), + ("feed", {"hunger": 40, "trust": 5}, "suspicious"), + ("play", {"energy": 8}, "tired"), + ("play", {"energy": 85, "happiness": 50}, "energetic"), + ("pet", {"trust": 5}, "low_trust"), + ("pet", {"trust": 85, "happiness": 50, "chaos": 10}, "high_trust"), + ("clean", {"cleanliness": 95}, "already_clean"), + ("clean", {"cleanliness": 10, "chaos": 10}, "dirty"), + ("sleep", {"energy": 90}, "too_energetic"), + ("sleep", {"energy": 15}, "grateful"), + ("wake", {"energy": 10}, "too_tired"), + ], +) +def test_variant_selection_matches_state(pet, command, state, expected): + set_state(pet, **state) + assert responses.variant_for(command, pet) == expected + + +def test_personality_shifts_chaotic_threshold(pet): + set_state(pet, hunger=40, trust=40, happiness=50, chaos=58) + + pet.personality = "calm and gentle" + assert responses.variant_for("feed", pet) == "normal" + + pet.personality = "chaotic, weird" + assert responses.variant_for("feed", pet) == "chaotic" + + +def test_responses_actually_vary(pet): + set_state(pet, hunger=40, energy=70, happiness=50, chaos=10, trust=40) + seen = {responses.for_command("feed", pet) for _ in range(40)} + assert len(seen) > 1 + + +def test_response_stays_short(pet): + for _ in range(30): + line = responses.for_command("feed", pet) + assert line.count(".") <= 4 + assert len(line) < 300 + + +@pytest.mark.parametrize( + "state", + [ + {"asleep": True}, + {"hunger": 90}, + {"energy": 10}, + {"happiness": 95, "energy": 70}, + {"happiness": 10}, + {"chaos": 95}, + {"cleanliness": 5}, + {}, + ], +) +def test_status_handles_all_states(pet, state): + set_state(pet, **state) + line = responses.status(pet) + assert line + assert pet.name in line + + +def test_pet_name_is_applied(pet): + pet.name = "Glorbo" + assert "Glorbo" in responses.status(pet) + + +@pytest.mark.parametrize( + "state,expected", + [ + ({"hunger": 90}, "hungry"), + ({"asleep": True}, "sleepy"), + ({"social": 10, "hunger": 40, "energy": 70, "chaos": 10, "cleanliness": 70}, "lonely"), + ({"cleanliness": 10, "hunger": 40, "energy": 70, "chaos": 10, "social": 60}, "dirty"), + ({"chaos": 95, "hunger": 40, "energy": 70}, "chaotic"), + ], +) +def test_autopost_group_reflects_state(pet, state, expected): + set_state(pet, **state) + assert responses.autopost_group(pet) == expected + + +def test_autopost_returns_varied_lines(pet): + set_state(pet, hunger=40, energy=70, happiness=50, social=60, chaos=10, cleanliness=70) + seen = {responses.autopost(pet) for _ in range(60)} + assert len(seen) > 1 + + +def test_asleep_rejection_and_no_op_responses(pet): + pet.asleep = True + assert responses.too_sleepy("feed", pet) + assert responses.already_asleep(pet) + assert responses.already_awake(pet)
@@ -0,0 +1,90 @@+from prami import repository as repo +from prami import safety + + +def settings_with(session, config, **overrides): + settings = repo.get_settings(session, config) + for key, value in overrides.items(): + setattr(settings, key, value) + return settings + + +def test_favourites_disabled_by_default(session, pet, user, config): + settings = repo.get_settings(session, config) + allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1") + assert allowed is False + + +def test_favourite_allowed_when_enabled(session, pet, user, config): + settings = settings_with(session, config, favourites_enabled=True) + allowed, why = safety.may_favourite(session, settings, config, user, "public", "1") + assert allowed is True + assert why == "ok" + + +def test_favourite_rejects_blocked_user(session, pet, user, config): + settings = settings_with(session, config, favourites_enabled=True) + user.blocked = True + allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1") + assert allowed is False + + +def test_favourite_rejects_private_by_default(session, pet, user, config): + settings = settings_with(session, config, favourites_enabled=True) + allowed, _ = safety.may_favourite(session, settings, config, user, "direct", "1") + assert allowed is False + + +def test_favourite_allows_private_when_configured(session, pet, user, config): + config.allow_favourite_private = True + settings = settings_with(session, config, favourites_enabled=True) + allowed, _ = safety.may_favourite(session, settings, config, user, "private", "1") + assert allowed is True + + +def test_favourite_no_duplicate(session, pet, user, config): + settings = settings_with(session, config, favourites_enabled=True) + repo.record_social_action(session, pet.id, user.id, "9", "favourite") + allowed, why = safety.may_favourite(session, settings, config, user, "public", "9") + assert allowed is False + assert "already" in why + + +def test_favourite_hourly_cap(session, pet, user, config): + config.max_favourites_per_hour = 1 + settings = settings_with(session, config, favourites_enabled=True) + repo.record_social_action(session, pet.id, user.id, "1", "favourite") + allowed, _ = safety.may_favourite(session, settings, config, user, "public", "2") + assert allowed is False + + +def test_boosts_disabled_by_default(session, pet, user, config): + settings = repo.get_settings(session, config) + allowed, _ = safety.may_boost(session, settings, config, user, "public", "1") + assert allowed is False + + +def test_boost_only_public(session, pet, user, config): + settings = settings_with(session, config, boosts_enabled=True) + allowed, _ = safety.may_boost(session, settings, config, user, "unlisted", "1") + assert allowed is False + + +def test_boost_allowed_for_public_when_enabled(session, pet, user, config): + settings = settings_with(session, config, boosts_enabled=True) + allowed, why = safety.may_boost(session, settings, config, user, "public", "1") + assert allowed is True + + +def test_boost_respects_min_interval(session, pet, user, config): + config.max_boosts_per_day = 5 + settings = settings_with(session, config, boosts_enabled=True) + repo.record_social_action(session, pet.id, user.id, "1", "boost") + allowed, why = safety.may_boost(session, settings, config, user, "public", "2") + assert allowed is False + + +def test_paused_blocks_favourites(session, pet, user, config): + settings = settings_with(session, config, favourites_enabled=True, paused=True) + allowed, _ = safety.may_favourite(session, settings, config, user, "public", "1") + assert allowed is False
@@ -0,0 +1,24 @@+from prami import social + + +def test_favourite_worthy_feed_when_starving(): + assert social.favourite_worthy("feed", {"hunger": 90, "social": 50, "cleanliness": 70}, None) is True + assert social.favourite_worthy("feed", {"hunger": 50, "social": 50, "cleanliness": 70}, None) is False + + +def test_favourite_worthy_pet_when_lonely(): + assert social.favourite_worthy("pet", {"hunger": 40, "social": 10, "cleanliness": 70}, None) is True + + +def test_favourite_worthy_clean_when_filthy(): + assert social.favourite_worthy("clean", {"hunger": 40, "social": 50, "cleanliness": 10}, None) is True + + +def test_favourite_not_worthy_for_ordinary_play(): + assert social.favourite_worthy("play", {"hunger": 40, "social": 50, "cleanliness": 70}, None) is False + + +def test_boost_worthy_only_on_critical_save(): + assert social.boost_worthy(True, False) is True + assert social.boost_worthy(False, False) is False + assert social.boost_worthy(True, True) is False
@@ -0,0 +1,94 @@+from datetime import timedelta + +from prami import decay, engine, events, memory, stats +from prami import repository as repo +from prami.models import utcnow + + +def _collect(session, config): + return stats.collect(session, config, repo.get_settings(session, config), utcnow()) + + +def test_counts_interactions_and_unique_users(session, pet, config): + a = repo.get_or_create_user(session, "a@x.com") + b = repo.get_or_create_user(session, "b@x.com") + repo.record_interaction(session, pet.id, a.id, "feed", None, True) + repo.record_interaction(session, pet.id, b.id, "pet", None, True) + repo.record_interaction(session, pet.id, a.id, "feed", None, True) + + data = _collect(session, config) + assert data["total_interactions"] == 3 + assert data["interactions_24h"] == 3 + assert data["unique_users_24h"] == 2 + assert ("feed", 2) in data["top_commands"] + + +def test_social_memory_and_blocked_counts(session, pet, user, config): + repo.record_social_action(session, pet.id, user.id, "1", "favourite") + repo.record_social_action(session, pet.id, user.id, "2", "boost") + config.enable_teach_command = True + memory.submit(session, pet, user, "Prami likes soup", config) + repo.block_user(session, "troll@bad.social") + + data = _collect(session, config) + assert data["favourites_24h"] == 1 + assert data["boosts_24h"] == 1 + assert data["pending_memories"] == 1 + assert data["blocked_users"] == 1 + + +def test_active_event_and_mood(session, pet, config): + pet.mood = "ravenous" + template = next(t for t in events.TEMPLATES if t["key"] == "egg") + events.start(session, pet, config, template=template) + + data = _collect(session, config) + assert data["active_event"] == "Prami found a mysterious egg." + assert data["mood"] == "ravenous" + + +def test_critical_stats_are_listed(session, pet, config): + pet.hunger = 95 + pet.energy = 5 + data = _collect(session, config) + assert any("hunger" in entry for entry in data["critical_stats"]) + assert any("energy" in entry for entry in data["critical_stats"]) + + +def test_uptime_is_none_without_startup(session, pet, config): + assert _collect(session, config)["uptime_seconds"] is None + + +def test_uptime_from_startup_event(session, pet, config): + repo.log_event(session, pet.id, "startup", "started") + session.flush() + data = stats.collect(session, config, repo.get_settings(session, config), utcnow() + timedelta(hours=2)) + assert data["uptime_seconds"] >= 7000 + + +def test_last_decay_tracked(session, pet, config): + assert _collect(session, config)["last_decay_at"] is None + decay.tick(pet) + assert _collect(session, config)["last_decay_at"] is not None + + +def test_database_status_connected(session, pet, config): + assert "connected" in _collect(session, config)["database"] + + +def test_render_is_readable(session, pet, config): + report = stats.text_report(session, config, repo.get_settings(session, config), utcnow()) + for label in ("uptime", "mood", "interactions", "database", "active event"): + assert label in report + + +def test_admin_stats_command(session, pet, user, config): + config.admin_accounts = [user.acct] + result = engine.handle(session, pet, user, "admin", config, arg="stats") + assert result is not None + assert "uptime" in result.text and "database" in result.text + + +def test_stats_not_available_to_non_admin(session, pet, user, config): + config.admin_accounts = [] + assert engine.handle(session, pet, user, "admin", config, arg="stats") is None
@@ -0,0 +1,26 @@+from prami import templates + + +def test_every_command_pool_is_well_formed(): + for command, variants in templates.COMMAND_TEMPLATES.items(): + assert "normal" in variants, f"{command} is missing a normal variant" + for variant, pool in variants.items(): + assert pool, f"{command}/{variant} is empty" + assert all(isinstance(line, str) and line.strip() for line in pool) + + +def test_autopost_pools_are_non_empty(): + for group, pool in templates.AUTOPOST.items(): + assert pool, f"autopost/{group} is empty" + + +def test_event_scenarios_are_well_formed(): + keys = set() + for scenario in templates.EVENT_SCENARIOS: + assert scenario["key"] and scenario["key"] not in keys + keys.add(scenario["key"]) + assert scenario["title"] and scenario["description"] + assert scenario["options"] + for option in scenario["options"].values(): + assert option["label"] and option["text"] + assert isinstance(option["deltas"], dict)