diff options
| author | 2025-10-09 21:58:43 +0300 | |
|---|---|---|
| committer | 2025-10-09 21:58:43 +0300 | |
| commit | 4da3ad1f569832845b58c3ce35149633a2bb665c (patch) | |
| tree | 5a09a0de66df7ec2e77f0fc9cc68ccbabc190934 | |
| download | ukkoclot-4da3ad1f569832845b58c3ce35149633a2bb665c.tar.gz ukkoclot-4da3ad1f569832845b58c3ce35149633a2bb665c.tar.xz ukkoclot-4da3ad1f569832845b58c3ce35149633a2bb665c.zip | |
Initial commit
33 files changed, 1943 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..960aab2 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | /config.lisp | ||
| 2 | /data.db | ||
| 3 | *.fasl | ||
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ea14b31 --- /dev/null +++ b/.pre-commit-config.yaml | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | # See https://pre-commit.com for more information | ||
| 2 | # See https://pre-commit.com/hooks.html for more hooks | ||
| 3 | repos: | ||
| 4 | - repo: https://github.com/fsfe/reuse-tool | ||
| 5 | rev: v6.1.2 | ||
| 6 | hooks: | ||
| 7 | - id: reuse | ||
| 8 | - repo: https://github.com/pre-commit/pre-commit-hooks | ||
| 9 | rev: v6.0.0 | ||
| 10 | hooks: | ||
| 11 | - id: check-added-large-files | ||
| 12 | - id: check-case-conflict | ||
| 13 | - id: check-executables-have-shebangs | ||
| 14 | - id: check-illegal-windows-names | ||
| 15 | - id: check-json | ||
| 16 | - id: check-merge-conflict | ||
| 17 | - id: check-shebang-scripts-are-executable | ||
| 18 | - id: check-symlinks | ||
| 19 | - id: check-toml | ||
| 20 | - id: check-xml | ||
| 21 | - id: check-yaml | ||
| 22 | - id: destroyed-symlinks | ||
| 23 | - id: detect-private-key | ||
| 24 | - id: end-of-file-fixer | ||
| 25 | - id: fix-byte-order-marker | ||
| 26 | - id: mixed-line-ending | ||
| 27 | args: [--fix=lf] | ||
| 28 | - id: pretty-format-json | ||
| 29 | args: [--autofix] | ||
| 30 | - id: trailing-whitespace | ||
| 31 | args: [--markdown-linebreak-ext=md] | ||
| 32 | - repo: https://github.com/stefmolin/exif-stripper | ||
| 33 | rev: 1.1.0 | ||
| 34 | hooks: | ||
| 35 | - id: strip-exif | ||
diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | Creative Commons Legal Code | ||
| 2 | |||
| 3 | CC0 1.0 Universal | ||
| 4 | |||
| 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE | ||
| 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN | ||
| 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS | ||
| 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES | ||
| 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS | ||
| 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM | ||
| 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED | ||
| 12 | HEREUNDER. | ||
| 13 | |||
| 14 | Statement of Purpose | ||
| 15 | |||
| 16 | The laws of most jurisdictions throughout the world automatically confer | ||
| 17 | exclusive Copyright and Related Rights (defined below) upon the creator | ||
| 18 | and subsequent owner(s) (each and all, an "owner") of an original work of | ||
| 19 | authorship and/or a database (each, a "Work"). | ||
| 20 | |||
| 21 | Certain owners wish to permanently relinquish those rights to a Work for | ||
| 22 | the purpose of contributing to a commons of creative, cultural and | ||
| 23 | scientific works ("Commons") that the public can reliably and without fear | ||
| 24 | of later claims of infringement build upon, modify, incorporate in other | ||
| 25 | works, reuse and redistribute as freely as possible in any form whatsoever | ||
| 26 | and for any purposes, including without limitation commercial purposes. | ||
| 27 | These owners may contribute to the Commons to promote the ideal of a free | ||
| 28 | culture and the further production of creative, cultural and scientific | ||
| 29 | works, or to gain reputation or greater distribution for their Work in | ||
| 30 | part through the use and efforts of others. | ||
| 31 | |||
| 32 | For these and/or other purposes and motivations, and without any | ||
| 33 | expectation of additional consideration or compensation, the person | ||
| 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she | ||
| 35 | is an owner of Copyright and Related Rights in the Work, voluntarily | ||
| 36 | elects to apply CC0 to the Work and publicly distribute the Work under its | ||
| 37 | terms, with knowledge of his or her Copyright and Related Rights in the | ||
| 38 | Work and the meaning and intended legal effect of CC0 on those rights. | ||
| 39 | |||
| 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be | ||
| 41 | protected by copyright and related or neighboring rights ("Copyright and | ||
| 42 | Related Rights"). Copyright and Related Rights include, but are not | ||
| 43 | limited to, the following: | ||
| 44 | |||
| 45 | i. the right to reproduce, adapt, distribute, perform, display, | ||
| 46 | communicate, and translate a Work; | ||
| 47 | ii. moral rights retained by the original author(s) and/or performer(s); | ||
| 48 | iii. publicity and privacy rights pertaining to a person's image or | ||
| 49 | likeness depicted in a Work; | ||
| 50 | iv. rights protecting against unfair competition in regards to a Work, | ||
| 51 | subject to the limitations in paragraph 4(a), below; | ||
| 52 | v. rights protecting the extraction, dissemination, use and reuse of data | ||
| 53 | in a Work; | ||
| 54 | vi. database rights (such as those arising under Directive 96/9/EC of the | ||
| 55 | European Parliament and of the Council of 11 March 1996 on the legal | ||
| 56 | protection of databases, and under any national implementation | ||
| 57 | thereof, including any amended or successor version of such | ||
| 58 | directive); and | ||
| 59 | vii. other similar, equivalent or corresponding rights throughout the | ||
| 60 | world based on applicable law or treaty, and any national | ||
| 61 | implementations thereof. | ||
| 62 | |||
| 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention | ||
| 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, | ||
| 65 | irrevocably and unconditionally waives, abandons, and surrenders all of | ||
| 66 | Affirmer's Copyright and Related Rights and associated claims and causes | ||
| 67 | of action, whether now known or unknown (including existing as well as | ||
| 68 | future claims and causes of action), in the Work (i) in all territories | ||
| 69 | worldwide, (ii) for the maximum duration provided by applicable law or | ||
| 70 | treaty (including future time extensions), (iii) in any current or future | ||
| 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, | ||
| 72 | including without limitation commercial, advertising or promotional | ||
| 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each | ||
| 74 | member of the public at large and to the detriment of Affirmer's heirs and | ||
| 75 | successors, fully intending that such Waiver shall not be subject to | ||
| 76 | revocation, rescission, cancellation, termination, or any other legal or | ||
| 77 | equitable action to disrupt the quiet enjoyment of the Work by the public | ||
| 78 | as contemplated by Affirmer's express Statement of Purpose. | ||
| 79 | |||
| 80 | 3. Public License Fallback. Should any part of the Waiver for any reason | ||
| 81 | be judged legally invalid or ineffective under applicable law, then the | ||
| 82 | Waiver shall be preserved to the maximum extent permitted taking into | ||
| 83 | account Affirmer's express Statement of Purpose. In addition, to the | ||
| 84 | extent the Waiver is so judged Affirmer hereby grants to each affected | ||
| 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, | ||
| 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and | ||
| 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the | ||
| 88 | maximum duration provided by applicable law or treaty (including future | ||
| 89 | time extensions), (iii) in any current or future medium and for any number | ||
| 90 | of copies, and (iv) for any purpose whatsoever, including without | ||
| 91 | limitation commercial, advertising or promotional purposes (the | ||
| 92 | "License"). The License shall be deemed effective as of the date CC0 was | ||
| 93 | applied by Affirmer to the Work. Should any part of the License for any | ||
| 94 | reason be judged legally invalid or ineffective under applicable law, such | ||
| 95 | partial invalidity or ineffectiveness shall not invalidate the remainder | ||
| 96 | of the License, and in such case Affirmer hereby affirms that he or she | ||
| 97 | will not (i) exercise any of his or her remaining Copyright and Related | ||
| 98 | Rights in the Work or (ii) assert any associated claims and causes of | ||
| 99 | action with respect to the Work, in either case contrary to Affirmer's | ||
| 100 | express Statement of Purpose. | ||
| 101 | |||
| 102 | 4. Limitations and Disclaimers. | ||
| 103 | |||
| 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, | ||
| 105 | surrendered, licensed or otherwise affected by this document. | ||
| 106 | b. Affirmer offers the Work as-is and makes no representations or | ||
| 107 | warranties of any kind concerning the Work, express, implied, | ||
| 108 | statutory or otherwise, including without limitation warranties of | ||
| 109 | title, merchantability, fitness for a particular purpose, non | ||
| 110 | infringement, or the absence of latent or other defects, accuracy, or | ||
| 111 | the present or absence of errors, whether or not discoverable, all to | ||
| 112 | the greatest extent permissible under applicable law. | ||
| 113 | c. Affirmer disclaims responsibility for clearing rights of other persons | ||
| 114 | that may apply to the Work or any use thereof, including without | ||
| 115 | limitation any person's Copyright and Related Rights in the Work. | ||
| 116 | Further, Affirmer disclaims responsibility for obtaining any necessary | ||
| 117 | consents, permissions or other rights required for any use of the | ||
| 118 | Work. | ||
| 119 | d. Affirmer understands and acknowledges that Creative Commons is not a | ||
| 120 | party to this document and has no duty or obligation with respect to | ||
| 121 | this CC0 or use of the Work. | ||
diff --git a/LICENSES/EUPL-1.2.txt b/LICENSES/EUPL-1.2.txt new file mode 100644 index 0000000..6d8cea4 --- /dev/null +++ b/LICENSES/EUPL-1.2.txt | |||
| @@ -0,0 +1,190 @@ | |||
| 1 | EUROPEAN UNION PUBLIC LICENCE v. 1.2 | ||
| 2 | EUPL © the European Union 2007, 2016 | ||
| 3 | |||
| 4 | This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined below) which is provided under the | ||
| 5 | terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such | ||
| 6 | use is covered by a right of the copyright holder of the Work). | ||
| 7 | The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following | ||
| 8 | notice immediately following the copyright notice for the Work: | ||
| 9 | Licensed under the EUPL | ||
| 10 | or has expressed by any other means his willingness to license under the EUPL. | ||
| 11 | |||
| 12 | 1.Definitions | ||
| 13 | In this Licence, the following terms have the following meaning: | ||
| 14 | — ‘The Licence’:this Licence. | ||
| 15 | — ‘The Original Work’:the work or software distributed or communicated by the Licensor under this Licence, available | ||
| 16 | as Source Code and also as Executable Code as the case may be. | ||
| 17 | — ‘Derivative Works’:the works or software that could be created by the Licensee, based upon the Original Work or | ||
| 18 | modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work | ||
| 19 | required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in | ||
| 20 | the country mentioned in Article 15. | ||
| 21 | — ‘The Work’:the Original Work or its Derivative Works. | ||
| 22 | — ‘The Source Code’:the human-readable form of the Work which is the most convenient for people to study and | ||
| 23 | modify. | ||
| 24 | — ‘The Executable Code’:any code which has generally been compiled and which is meant to be interpreted by | ||
| 25 | a computer as a program. | ||
| 26 | — ‘The Licensor’:the natural or legal person that distributes or communicates the Work under the Licence. | ||
| 27 | — ‘Contributor(s)’:any natural or legal person who modifies the Work under the Licence, or otherwise contributes to | ||
| 28 | the creation of a Derivative Work. | ||
| 29 | — ‘The Licensee’ or ‘You’:any natural or legal person who makes any usage of the Work under the terms of the | ||
| 30 | Licence. | ||
| 31 | — ‘Distribution’ or ‘Communication’:any act of selling, giving, lending, renting, distributing, communicating, | ||
| 32 | transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential | ||
| 33 | functionalities at the disposal of any other natural or legal person. | ||
| 34 | |||
| 35 | 2.Scope of the rights granted by the Licence | ||
| 36 | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for | ||
| 37 | the duration of copyright vested in the Original Work: | ||
| 38 | — use the Work in any circumstance and for all usage, | ||
| 39 | — reproduce the Work, | ||
| 40 | — modify the Work, and make Derivative Works based upon the Work, | ||
| 41 | — communicate to the public, including the right to make available or display the Work or copies thereof to the public | ||
| 42 | and perform publicly, as the case may be, the Work, | ||
| 43 | — distribute the Work or copies thereof, | ||
| 44 | — lend and rent the Work or copies thereof, | ||
| 45 | — sublicense rights in the Work or copies thereof. | ||
| 46 | Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the | ||
| 47 | applicable law permits so. | ||
| 48 | In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed | ||
| 49 | by law in order to make effective the licence of the economic rights here above listed. | ||
| 50 | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the | ||
| 51 | extent necessary to make use of the rights granted on the Work under this Licence. | ||
| 52 | |||
| 53 | 3.Communication of the Source Code | ||
| 54 | The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as | ||
| 55 | Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with | ||
| 56 | each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to | ||
| 57 | the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to | ||
| 58 | distribute or communicate the Work. | ||
| 59 | |||
| 60 | 4.Limitations on copyright | ||
| 61 | Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the | ||
| 62 | exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations | ||
| 63 | thereto. | ||
| 64 | |||
| 65 | 5.Obligations of the Licensee | ||
| 66 | The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those | ||
| 67 | obligations are the following: | ||
| 68 | |||
| 69 | Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to | ||
| 70 | the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the | ||
| 71 | Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work | ||
| 72 | to carry prominent notices stating that the Work has been modified and the date of modification. | ||
| 73 | |||
| 74 | Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this | ||
| 75 | Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless | ||
| 76 | the Original Work is expressly distributed only under this version of the Licence — for example by communicating | ||
| 77 | ‘EUPL v. 1.2 only’. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the | ||
| 78 | Work or Derivative Work that alter or restrict the terms of the Licence. | ||
| 79 | |||
| 80 | Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both | ||
| 81 | the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done | ||
| 82 | under the terms of this Compatible Licence. For the sake of this clause, ‘Compatible Licence’ refers to the licences listed | ||
| 83 | in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with | ||
| 84 | his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail. | ||
| 85 | |||
| 86 | Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide | ||
| 87 | a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available | ||
| 88 | for as long as the Licensee continues to distribute or communicate the Work. | ||
| 89 | Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names | ||
| 90 | of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and | ||
| 91 | reproducing the content of the copyright notice. | ||
| 92 | |||
| 93 | 6.Chain of Authorship | ||
| 94 | The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or | ||
| 95 | licensed to him/her and that he/she has the power and authority to grant the Licence. | ||
| 96 | Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or | ||
| 97 | licensed to him/her and that he/she has the power and authority to grant the Licence. | ||
| 98 | Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions | ||
| 99 | to the Work, under the terms of this Licence. | ||
| 100 | |||
| 101 | 7.Disclaimer of Warranty | ||
| 102 | The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work | ||
| 103 | and may therefore contain defects or ‘bugs’ inherent to this type of development. | ||
| 104 | For the above reason, the Work is provided under the Licence on an ‘as is’ basis and without warranties of any kind | ||
| 105 | concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or | ||
| 106 | errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this | ||
| 107 | Licence. | ||
| 108 | This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work. | ||
| 109 | |||
| 110 | 8.Disclaimer of Liability | ||
| 111 | Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be | ||
| 112 | liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the | ||
| 113 | Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss | ||
| 114 | of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However, | ||
| 115 | the Licensor will be liable under statutory product liability laws as far such laws apply to the Work. | ||
| 116 | |||
| 117 | 9.Additional agreements | ||
| 118 | While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services | ||
| 119 | consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole | ||
| 120 | responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify, | ||
| 121 | defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by | ||
| 122 | the fact You have accepted any warranty or additional liability. | ||
| 123 | |||
| 124 | 10.Acceptance of the Licence | ||
| 125 | The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ placed under the bottom of a window | ||
| 126 | displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of | ||
| 127 | applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms | ||
| 128 | and conditions. | ||
| 129 | Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You | ||
| 130 | by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution | ||
| 131 | or Communication by You of the Work or copies thereof. | ||
| 132 | |||
| 133 | 11.Information to the public | ||
| 134 | In case of any Distribution or Communication of the Work by means of electronic communication by You (for example, | ||
| 135 | by offering to download the Work from a remote location) the distribution channel or media (for example, a website) | ||
| 136 | must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence | ||
| 137 | and the way it may be accessible, concluded, stored and reproduced by the Licensee. | ||
| 138 | |||
| 139 | 12.Termination of the Licence | ||
| 140 | The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms | ||
| 141 | of the Licence. | ||
| 142 | Such a termination will not terminate the licences of any person who has received the Work from the Licensee under | ||
| 143 | the Licence, provided such persons remain in full compliance with the Licence. | ||
| 144 | |||
| 145 | 13.Miscellaneous | ||
| 146 | Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the | ||
| 147 | Work. | ||
| 148 | If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or | ||
| 149 | enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid | ||
| 150 | and enforceable. | ||
| 151 | The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of | ||
| 152 | the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence. | ||
| 153 | New versions of the Licence will be published with a unique version number. | ||
| 154 | All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take | ||
| 155 | advantage of the linguistic version of their choice. | ||
| 156 | |||
| 157 | 14.Jurisdiction | ||
| 158 | Without prejudice to specific agreement between parties, | ||
| 159 | — any litigation resulting from the interpretation of this License, arising between the European Union institutions, | ||
| 160 | bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice | ||
| 161 | of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union, | ||
| 162 | — any litigation arising between other parties and resulting from the interpretation of this License, will be subject to | ||
| 163 | the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business. | ||
| 164 | |||
| 165 | 15.Applicable Law | ||
| 166 | Without prejudice to specific agreement between parties, | ||
| 167 | — this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat, | ||
| 168 | resides or has his registered office, | ||
| 169 | — this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside | ||
| 170 | a European Union Member State. | ||
| 171 | |||
| 172 | |||
| 173 | Appendix | ||
| 174 | |||
| 175 | ‘Compatible Licences’ according to Article 5 EUPL are: | ||
| 176 | — GNU General Public License (GPL) v. 2, v. 3 | ||
| 177 | — GNU Affero General Public License (AGPL) v. 3 | ||
| 178 | — Open Software License (OSL) v. 2.1, v. 3.0 | ||
| 179 | — Eclipse Public License (EPL) v. 1.0 | ||
| 180 | — CeCILL v. 2.0, v. 2.1 | ||
| 181 | — Mozilla Public Licence (MPL) v. 2 | ||
| 182 | — GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 | ||
| 183 | — Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software | ||
| 184 | — European Union Public Licence (EUPL) v. 1.1, v. 1.2 | ||
| 185 | — Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+). | ||
| 186 | |||
| 187 | The European Commission may update this Appendix to later versions of the above licences without producing | ||
| 188 | a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the | ||
| 189 | covered Source Code from exclusive appropriation. | ||
| 190 | All other changes or additions to this Appendix require the production of a new EUPL version. | ||
diff --git a/README.md b/README.md new file mode 100644 index 0000000..76b3e5e --- /dev/null +++ b/README.md | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | # Ukkoclot | ||
| 2 | |||
| 3 | A shitty small telegram bot written in common lisp. | ||
| 4 | |||
| 5 | # Licensing | ||
| 6 | |||
| 7 | European Union Public Licence, version 1.2. | ||
diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..5798adc --- /dev/null +++ b/REUSE.toml | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | # See <https://reuse.software/spec-3.2/> | ||
| 2 | version = 1 | ||
| 3 | |||
| 4 | [[annotations]] | ||
| 5 | label = "Miscellaneous little shits I put under CC0" | ||
| 6 | path = [".gitignore", ".pre-commit-config.yaml", "config.default.lisp"] | ||
| 7 | SPDX-License-Identifier = "CC0-1.0" | ||
| 8 | SPDX-FileCopyrightText = " 2025 Uko Kokņevičs <perkontevs@gmail.com>" | ||
| 9 | |||
| 10 | [[annotations]] | ||
| 11 | label = "Docs or something" | ||
| 12 | path = ["README.md"] | ||
| 13 | SPDX-License-Identifier = "EUPL-1.2" | ||
| 14 | SPDX-FileCopyrightText = " 2025 Uko Kokņevičs <perkontevs@gmail.com>" | ||
diff --git a/config.default.lisp b/config.default.lisp new file mode 100644 index 0000000..39b80c8 --- /dev/null +++ b/config.default.lisp | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | ;; Copy this file to config.lisp and modify it there | ||
| 2 | (:bot-name "Ukko's Clot" | ||
| 3 | :bot-token "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi" | ||
| 4 | :db-path "./data.db" | ||
| 5 | :dev-group -1001234567890 | ||
| 6 | :owner 12345678) | ||
diff --git a/src/bot.lisp b/src/bot.lisp new file mode 100644 index 0000000..a51402d --- /dev/null +++ b/src/bot.lisp | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (uiop:define-package :ukkoclot/bot | ||
| 4 | (:use) | ||
| 5 | ;; Maybe should somehow hide BOT-USERNAME% and BOT-ID% but whatever | ||
| 6 | (:use-reexport :ukkoclot/bot/impl :ukkoclot/bot/methods)) | ||
diff --git a/src/bot/impl.lisp b/src/bot/impl.lisp new file mode 100644 index 0000000..b57e2d3 --- /dev/null +++ b/src/bot/impl.lisp | |||
| @@ -0,0 +1,96 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/bot/impl | ||
| 4 | (:use :c2cl :iterate :ukkoclot/config :ukkoclot/log) | ||
| 5 | (:import-from :anaphora :aand :acond :it) | ||
| 6 | (:import-from :dex) | ||
| 7 | (:import-from :ukkoclot/strings :lisp->snake-case) | ||
| 8 | (:local-nicknames | ||
| 9 | (:jzon :com.inuoe.jzon)) | ||
| 10 | (:export | ||
| 11 | :arg-encode :bot :bot-p :make-bot :do-call | ||
| 12 | |||
| 13 | :bot-config :bot-db :bot-base-uri :bot-power-on :bot-username% :bot-id%)) | ||
| 14 | (in-package :ukkoclot/bot/impl) | ||
| 15 | |||
| 16 | (defgeneric will-arg-encode (object) | ||
| 17 | (:documentation "Whether the OBJECT has any transformation done to it by arg-encode") | ||
| 18 | (:method (obj) | ||
| 19 | nil) | ||
| 20 | (:method ((obj cons)) | ||
| 21 | (or (will-arg-encode (car obj)) | ||
| 22 | (will-arg-encode (cdr obj))))) | ||
| 23 | |||
| 24 | (defgeneric arg-encode (object) | ||
| 25 | (:documentation "Encode the OBJECT to a form that our HTTP code is happy about.") | ||
| 26 | (:method (obj) | ||
| 27 | obj) | ||
| 28 | (:method ((obj cons)) | ||
| 29 | (if (not (will-arg-encode obj)) | ||
| 30 | obj | ||
| 31 | (cons (arg-encode (car obj)) | ||
| 32 | (arg-encode (cdr obj)))))) | ||
| 33 | |||
| 34 | (defgeneric fixup-arg (value) | ||
| 35 | (:documentation "Make sure Telegram & QURI & whatever like the arg") | ||
| 36 | (:method (value) | ||
| 37 | (error "Unsupported argument type ~S (~S)" (class-name (class-of value)) value)) | ||
| 38 | (:method ((value null)) | ||
| 39 | value) | ||
| 40 | (:method ((value number)) | ||
| 41 | value) | ||
| 42 | (:method ((value string)) | ||
| 43 | value) | ||
| 44 | (:method ((value hash-table)) | ||
| 45 | (jzon:stringify value))) | ||
| 46 | |||
| 47 | (defstruct (bot (:constructor make-bot%)) | ||
| 48 | (config (error "No value given for config") :read-only t) | ||
| 49 | (db (error "No value given for DB") :read-only t) | ||
| 50 | (base-uri (error "No value given for base-uri") :read-only t) | ||
| 51 | (power-on t :type boolean) | ||
| 52 | (username% nil :type (or string null)) | ||
| 53 | (id% nil :type (or integer null))) | ||
| 54 | |||
| 55 | (defun make-bot (config db) | ||
| 56 | (let ((base-uri (concatenate 'string | ||
| 57 | "https://api.telegram.org/bot" | ||
| 58 | (config-bot-token config) "/"))) | ||
| 59 | (make-bot% :config config :db db :base-uri base-uri))) | ||
| 60 | |||
| 61 | (defun args-plist->alist (args-plist) | ||
| 62 | (iter (for (old-key value) on args-plist by #'cddr) | ||
| 63 | (collect | ||
| 64 | (let ((new-key (string-downcase (lisp->snake-case (symbol-name old-key))))) | ||
| 65 | (cons new-key value))))) | ||
| 66 | |||
| 67 | (defun fixup-args (args-alist) | ||
| 68 | (iter (for (name . value) in args-alist) | ||
| 69 | (collecting (cons name (fixup-arg (arg-encode value)))))) | ||
| 70 | |||
| 71 | (defun req (uri method content) | ||
| 72 | ;; We deal with this manually | ||
| 73 | (handler-bind ((dex:http-request-too-many-requests #'dex:ignore-and-continue)) | ||
| 74 | (dex:request uri :method method :content content))) | ||
| 75 | |||
| 76 | (defun do-call% (bot method uri mapfn args-encoded) | ||
| 77 | (let ((body (req uri method args-encoded))) | ||
| 78 | (let ((hash (jzon:parse body))) | ||
| 79 | (acond | ||
| 80 | ((gethash "ok" hash) (funcall mapfn (gethash "result" hash))) | ||
| 81 | ((aand (gethash "parameters" hash) | ||
| 82 | (gethash "retry_after" it)) | ||
| 83 | (log-info "Should sleep for ~A seconds" it) | ||
| 84 | (sleep it) | ||
| 85 | (log-info "Good morning!") | ||
| 86 | (do-call% bot method uri mapfn args-encoded)) | ||
| 87 | (t (error "TG error ~A: ~A ~:A" | ||
| 88 | (gethash "error_code" hash) | ||
| 89 | (gethash "description" hash) | ||
| 90 | (gethash "parameters" hash))))))) | ||
| 91 | |||
| 92 | (defun do-call (bot method path mapfn args-plist) | ||
| 93 | (let ((uri (concatenate 'string (bot-base-uri bot) path)) | ||
| 94 | (args-encoded (fixup-args (args-plist->alist args-plist)))) | ||
| 95 | (log-debug "~A .../~A ~S" method path args-encoded) | ||
| 96 | (do-call% bot method uri mapfn args-encoded))) | ||
diff --git a/src/bot/methods.lisp b/src/bot/methods.lisp new file mode 100644 index 0000000..b0eca5c --- /dev/null +++ b/src/bot/methods.lisp | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/bot/methods | ||
| 4 | (:use :c2cl :ukkoclot/bot/impl :ukkoclot/tg-types :ukkoclot/tg-types/macros) | ||
| 5 | (:export :answer-callback-query :bot-id :bot-username :delete-message :edit-message-text :get-me :get-updates :send-message :set-my-name)) | ||
| 6 | (in-package :ukkoclot/bot/methods) | ||
| 7 | |||
| 8 | (define-tg-method (answer-callback-query boolean "answerCallbackQuery" #'identity) | ||
| 9 | (callback-query-id string) | ||
| 10 | (text (or string null) nil) | ||
| 11 | (show-alert boolean nil) | ||
| 12 | (url (or string null) nil) | ||
| 13 | (cache-time (or integer null) nil)) | ||
| 14 | |||
| 15 | (defun bot-id (bot) | ||
| 16 | (or (bot-id% bot) | ||
| 17 | (progn | ||
| 18 | (get-me bot) | ||
| 19 | (bot-id% bot)))) | ||
| 20 | |||
| 21 | (defun bot-username (bot) | ||
| 22 | (or (bot-username% bot) | ||
| 23 | (progn | ||
| 24 | (get-me bot) | ||
| 25 | (bot-username% bot)))) | ||
| 26 | |||
| 27 | (define-tg-method (delete-message boolean "deleteMessage" #'identity) | ||
| 28 | (chat-id (or integer string)) | ||
| 29 | (message-id integer)) | ||
| 30 | |||
| 31 | (define-tg-method (edit-message-text message "editMessageText" #'hash->message) | ||
| 32 | (business-connection-id (or string null) nil) | ||
| 33 | (chat-id (or integer string null) nil) | ||
| 34 | (message-id (or integer null) nil) | ||
| 35 | (inline-message-id (or string null) nil) | ||
| 36 | (text string) | ||
| 37 | (parse-mode (or string null) nil) | ||
| 38 | (entities (or (array message-entity) null) nil) | ||
| 39 | (link-preview-options (or link-preview-options null) nil) | ||
| 40 | (reply-markup (or inline-keyboard-markup null) nil)) | ||
| 41 | |||
| 42 | (define-tg-method (get-me% user "getMe" #'hash->user :GET)) | ||
| 43 | |||
| 44 | (defun get-me (bot) | ||
| 45 | (let ((res (get-me% bot))) | ||
| 46 | (setf (bot-id% bot) (user-id res)) | ||
| 47 | (setf (bot-username% bot) (user-username res)) | ||
| 48 | res)) | ||
| 49 | |||
| 50 | (define-tg-method (get-my-name bot-name "getMyName" #'hash->bot-name :GET) | ||
| 51 | (language-code (or string null) nil)) | ||
| 52 | |||
| 53 | (define-tg-method (get-updates (array update) "getUpdates" #'parse-update-array) | ||
| 54 | (offset (or integer null) nil) | ||
| 55 | (limit (or integer null) nil) | ||
| 56 | (timeout (or integer null) nil) | ||
| 57 | (allowed-updates (or string null) nil)) | ||
| 58 | |||
| 59 | (define-tg-method (send-message message "sendMessage" #'hash->message) | ||
| 60 | (business-connection-id (or string null) nil) | ||
| 61 | (chat-id (or integer string)) | ||
| 62 | (message-thread-id (or integer null) nil) | ||
| 63 | (text string) | ||
| 64 | ;; TODO: parse-mode should maybe be keywords? | ||
| 65 | (parse-mode (or string null) nil) | ||
| 66 | (entities (or (array message-entity) null) nil) | ||
| 67 | (link-preview-options (or link-preview-options null) nil) | ||
| 68 | (disable-notification (or boolean null) nil) | ||
| 69 | (protect-content (or boolean null) nil) | ||
| 70 | (message-effect-id (or string null) nil) | ||
| 71 | (reply-parameters (or reply-parameters null) nil) | ||
| 72 | (reply-markup (or inline-keyboard-markup | ||
| 73 | ;; TODO: reply-keyboard-markup | ||
| 74 | ;; TODO: reply-keyboard-remove | ||
| 75 | force-reply null) nil)) | ||
| 76 | |||
| 77 | (define-tg-method (set-my-name% boolean "setMyName" #'identity) | ||
| 78 | (name (or string null) nil) | ||
| 79 | (language-code (or string null) nil)) | ||
| 80 | |||
| 81 | (defun set-my-name (bot &key (name nil) (language-code nil)) | ||
| 82 | (block nil | ||
| 83 | (when name | ||
| 84 | (let ((curr-name (get-my-name bot :language-code language-code))) | ||
| 85 | (when (string= name (bot-name-name curr-name)) | ||
| 86 | (return)))) | ||
| 87 | (unless (set-my-name% bot :name name :language-code language-code) | ||
| 88 | (error "Failed to set name")))) | ||
diff --git a/src/config.lisp b/src/config.lisp new file mode 100644 index 0000000..86a0f33 --- /dev/null +++ b/src/config.lisp | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/config | ||
| 4 | (:use :c2cl :ukkoclot/hash-tables :ukkoclot/log) | ||
| 5 | (:export | ||
| 6 | :config-load :config-merge | ||
| 7 | :config-p | ||
| 8 | :config-bot-name :config-bot-token :config-db-path :config-dev-group :config-owner)) | ||
| 9 | (in-package :ukkoclot/config) | ||
| 10 | |||
| 11 | (defmacro defconfig (&rest slots-and-types) | ||
| 12 | `(defstruct config | ||
| 13 | ,@(loop for (name type) on slots-and-types by #'cddr | ||
| 14 | collect `(,(intern (symbol-name name)) (error "No value given for ~A" ,name) :type ,type :read-only t)))) | ||
| 15 | |||
| 16 | (defconfig | ||
| 17 | :bot-name string | ||
| 18 | :bot-token string | ||
| 19 | :db-path string | ||
| 20 | :dev-group integer | ||
| 21 | :owner integer) | ||
| 22 | |||
| 23 | (defun config-load (filename) | ||
| 24 | (apply #'make-config (with-open-file (f filename) (read f)))) | ||
| 25 | |||
| 26 | (defun config-merge (config filename) | ||
| 27 | (loop for (name value) on (with-open-file (f filename) (read f)) by #'cddr do | ||
| 28 | (setf (slot-value config (intern (symbol-name name) :ukkoclot/config)) value))) | ||
diff --git a/src/db.lisp b/src/db.lisp new file mode 100644 index 0000000..9b646d2 --- /dev/null +++ b/src/db.lisp | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/db | ||
| 4 | (:use :c2cl :sqlite :ukkoclot/log) | ||
| 5 | (:export :get-inline-bot-type :set-inline-bot-type :with-db)) | ||
| 6 | (in-package :ukkoclot/db) | ||
| 7 | |||
| 8 | (defparameter +target-version+ 1 | ||
| 9 | "Intended DB version") | ||
| 10 | |||
| 11 | (defmacro with-db ((name path) &body body) | ||
| 12 | `(let ((,name (connect ,path))) | ||
| 13 | (unwind-protect (progn (upgrade ,name) ,@body) | ||
| 14 | (disconnect ,name)))) | ||
| 15 | |||
| 16 | (defun get-inline-bot-type (db id) | ||
| 17 | (let ((type-int (execute-single db "SELECT type FROM inline_bots WHERE id = ?" id))) | ||
| 18 | (when type-int | ||
| 19 | (integer->inline-bot-type type-int)))) | ||
| 20 | |||
| 21 | (defun set-inline-bot-type (db id type) | ||
| 22 | (execute-non-query db "INSERT OR REPLACE INTO inline_bots (id, type) VALUES (?, ?)" id (inline-bot-type->integer type))) | ||
| 23 | |||
| 24 | (defun inline-bot-type->integer (type) | ||
| 25 | (case type | ||
| 26 | (:blacklisted 0) | ||
| 27 | (:whitelisted 1) | ||
| 28 | (t (error "Unknown inline bot type ~S" type)))) | ||
| 29 | |||
| 30 | (defun integer->inline-bot-type (num) | ||
| 31 | (case num | ||
| 32 | (0 :blacklisted) | ||
| 33 | (1 :whitelisted) | ||
| 34 | (t (error "Unknown inline bot type value ~S" num)))) | ||
| 35 | |||
| 36 | (defun upgrade (db) | ||
| 37 | (execute-non-query db "CREATE TABLE IF NOT EXISTS version(id INTEGER PRIMARY KEY, version INTEGER)") | ||
| 38 | (let ((current-ver (execute-single db "SELECT version FROM version WHERE id = 0"))) | ||
| 39 | (unless current-ver | ||
| 40 | (setf current-ver 0)) | ||
| 41 | (cond | ||
| 42 | ((= current-ver +target-version+) (log-info "Database is up to date")) | ||
| 43 | |||
| 44 | ((> current-ver +target-version+) | ||
| 45 | (log-error "Database has a higher version than supported?") | ||
| 46 | (error "Corrupted Database")) | ||
| 47 | |||
| 48 | (t | ||
| 49 | (log-info "Updating database from version ~A to ~A" current-ver +target-version+) | ||
| 50 | (loop while (< current-ver +target-version+) | ||
| 51 | do (with-transaction db | ||
| 52 | (log-info "Updating database step from ~A" current-ver) | ||
| 53 | (incf current-ver) | ||
| 54 | (upgrade-step db current-ver) | ||
| 55 | (execute-non-query | ||
| 56 | db | ||
| 57 | "INSERT OR REPLACE INTO version(id, version) VALUES (0, ?)" | ||
| 58 | current-ver))) | ||
| 59 | (log-info "Database updating complete :)"))))) | ||
| 60 | |||
| 61 | (defun upgrade-step (db new-version) | ||
| 62 | (case new-version | ||
| 63 | (1 | ||
| 64 | (execute-non-query db "DROP TABLE IF EXISTS inline_bots_enum") | ||
| 65 | (execute-non-query db " | ||
| 66 | CREATE TABLE inline_bots_enum ( | ||
| 67 | id INTEGER PRIMARY KEY, | ||
| 68 | value TEXT UNIQUE | ||
| 69 | )") | ||
| 70 | (execute-non-query db " | ||
| 71 | INSERT INTO inline_bots_enum(id, value) | ||
| 72 | VALUES (?, 'blacklisted'), (?, 'whitelisted')" | ||
| 73 | (inline-bot-type->integer :blacklisted) | ||
| 74 | (inline-bot-type->integer :whitelisted)) | ||
| 75 | |||
| 76 | (execute-non-query db "DROP TABLE IF EXISTS inline_bots") | ||
| 77 | (execute-non-query db " | ||
| 78 | CREATE TABLE inline_bots ( | ||
| 79 | id INTEGER PRIMARY KEY, | ||
| 80 | type INTEGER REFERENCES inline_bots_enum(id) | ||
| 81 | )")) | ||
| 82 | (t (error "Unreachable upgrade step reached ~A" new-version)))) | ||
diff --git a/src/hash-tables.lisp b/src/hash-tables.lisp new file mode 100644 index 0000000..9e41b26 --- /dev/null +++ b/src/hash-tables.lisp | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/hash-tables | ||
| 4 | (:use :c2cl) | ||
| 5 | (:export :alist->hash-table :gethash-lazy :plist->hash-table)) | ||
| 6 | (in-package :ukkoclot/hash-tables) | ||
| 7 | |||
| 8 | (defun alist->hash-table (alist &rest args &key &allow-other-keys) | ||
| 9 | (let ((ht (apply #'make-hash-table args))) | ||
| 10 | (loop for (key . value) in alist do | ||
| 11 | (setf (gethash key ht) value)) | ||
| 12 | ht)) | ||
| 13 | |||
| 14 | (defmacro gethash-lazy (key hash-table default-lazy) | ||
| 15 | (let ((unique (gensym "UNIQUE-")) | ||
| 16 | (res (gensym "RES-"))) | ||
| 17 | `(let* ((,unique ',unique) | ||
| 18 | (,res (gethash ,key ,hash-table ,unique))) | ||
| 19 | (if (eq ,res ,unique) | ||
| 20 | ,default-lazy | ||
| 21 | ,res)))) | ||
| 22 | |||
| 23 | (defun plist->hash-table (plist &rest args &key &allow-other-keys) | ||
| 24 | (let ((ht (apply #'make-hash-table args))) | ||
| 25 | (loop for (key value) on plist by #'cddr do | ||
| 26 | (setf (gethash key ht) value)) | ||
| 27 | ht)) | ||
diff --git a/src/inline-bots.lisp b/src/inline-bots.lisp new file mode 100644 index 0000000..5945084 --- /dev/null +++ b/src/inline-bots.lisp | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/inline-bots | ||
| 4 | (:use :c2cl :ukkoclot/bot :ukkoclot/config :ukkoclot/log :ukkoclot/tg-types) | ||
| 5 | (:local-nicknames (:db :ukkoclot/db)) | ||
| 6 | (:export :blacklist-inline-bot :on-inline-bot :whitelist-inline-bot)) | ||
| 7 | (in-package :ukkoclot/inline-bots) | ||
| 8 | |||
| 9 | (defun blacklist-inline-bot (bot inline-bot-id) | ||
| 10 | (db:set-inline-bot-type (bot-db bot) inline-bot-id :blacklisted)) | ||
| 11 | |||
| 12 | (defun whitelist-inline-bot (bot inline-bot-id) | ||
| 13 | (db:set-inline-bot-type (bot-db bot) inline-bot-id :whitelisted)) | ||
| 14 | |||
| 15 | (defun on-inline-bot (bot msg via) | ||
| 16 | (let ((ty (db:get-inline-bot-type (bot-db bot) (user-id via)))) | ||
| 17 | (if (eq ty :whitelisted) | ||
| 18 | t | ||
| 19 | (progn | ||
| 20 | (log-info "Deleting an unallowed inline bot message from ~A ~A" | ||
| 21 | (user-username via) | ||
| 22 | (user-id via)) | ||
| 23 | (delete-message bot | ||
| 24 | :chat-id (message-chat-id msg) | ||
| 25 | :message-id (message-id msg)) | ||
| 26 | (unless (eq ty :blacklisted) | ||
| 27 | ;; Not explicitly blacklisted, notify dev group | ||
| 28 | (send-message bot | ||
| 29 | :chat-id (config-dev-group (bot-config bot)) | ||
| 30 | :text (format nil "Deleted a message sent via inline bot @~A <code>~A</code>" | ||
| 31 | (user-username via) | ||
| 32 | (user-id via)) | ||
| 33 | :parse-mode "HTML" | ||
| 34 | :reply-markup (make-inline-keyboard-markup | ||
| 35 | :inline-keyboard | ||
| 36 | #(#((make-inline-keyboard-button | ||
| 37 | :text "Whitelist" | ||
| 38 | :callback-data (format nil "bwl:~A" (user-id via))) | ||
| 39 | (make-inline-keyboard-button | ||
| 40 | :text "Blacklist" | ||
| 41 | :callback-data (format nil "bbl:~A" (user-id via)))))))) | ||
| 42 | nil)))) | ||
diff --git a/src/log.lisp b/src/log.lisp new file mode 100644 index 0000000..e6e8661 --- /dev/null +++ b/src/log.lisp | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/log | ||
| 4 | (:use :c2cl) | ||
| 5 | (:export :*output* :deflevel :log-error :log-warn :log-info :log-debug)) | ||
| 6 | (in-package :ukkoclot/log) | ||
| 7 | |||
| 8 | (defparameter *output* *error-output*) | ||
| 9 | |||
| 10 | (defvar *max-name-length* 8) | ||
| 11 | (defvar *max-level-length* 4) | ||
| 12 | |||
| 13 | (defvar *levels* nil) | ||
| 14 | |||
| 15 | (defun get-levels () | ||
| 16 | (unless *levels* | ||
| 17 | (setf *levels* (make-hash-table :test #'eq))) | ||
| 18 | *levels*) | ||
| 19 | |||
| 20 | (defun register-level (level value) | ||
| 21 | (setf (gethash level (get-levels)) value) | ||
| 22 | (let ((l (length (symbol-name level)))) | ||
| 23 | (when (> l *max-level-length*) | ||
| 24 | (setf *max-level-length* l)))) | ||
| 25 | |||
| 26 | (defun level-value (level) | ||
| 27 | (let ((value (gethash level (get-levels)))) | ||
| 28 | (if value | ||
| 29 | value | ||
| 30 | (progn | ||
| 31 | (format *output* "UKKOLOG INTERNAL WARN: UNKNOWN LEVEL ~A" level) | ||
| 32 | 1000)))) | ||
| 33 | |||
| 34 | (defun level< (lhs rhs) | ||
| 35 | (< (level-value lhs) (level-value rhs))) | ||
| 36 | |||
| 37 | (defstruct (logger (:constructor make-logger%)) | ||
| 38 | (name (error "No value given for NAME") :type keyword :read-only t) | ||
| 39 | (min-level :debug :type keyword)) ;TODO: Make this :info and make it configurable | ||
| 40 | |||
| 41 | (defun make-logger (name) | ||
| 42 | (let ((l (length (symbol-name name)))) | ||
| 43 | (when (> l *max-name-length*) | ||
| 44 | (setf *max-name-length* l))) | ||
| 45 | (make-logger% :name name)) | ||
| 46 | |||
| 47 | (defvar *package-loggers* nil) | ||
| 48 | |||
| 49 | (defun get-package-loggers () | ||
| 50 | (unless *package-loggers* | ||
| 51 | (setf *package-loggers* (make-hash-table :test #'eq))) | ||
| 52 | *package-loggers*) | ||
| 53 | |||
| 54 | (defun get-package-logger (package) | ||
| 55 | (let* ((name (package-name package)) | ||
| 56 | (name-sym (intern name :keyword)) | ||
| 57 | (loggers (get-package-loggers)) | ||
| 58 | (logger (gethash name-sym loggers))) | ||
| 59 | (unless logger | ||
| 60 | (setf logger (make-logger name-sym)) | ||
| 61 | (setf (gethash name-sym loggers) logger)) | ||
| 62 | logger)) | ||
| 63 | |||
| 64 | (defun perform-log (package level fmt-str &rest args) | ||
| 65 | (let ((logger (get-package-logger package))) | ||
| 66 | (unless (level< level (logger-min-level logger)) | ||
| 67 | (apply #'format *output* | ||
| 68 | (concatenate 'string "~&~v@A: ~v@A: " fmt-str "~%") | ||
| 69 | *max-name-length* (logger-name logger) | ||
| 70 | *max-level-length* level | ||
| 71 | args)))) | ||
| 72 | |||
| 73 | (defmacro p (level fmt-str &rest args) | ||
| 74 | `(perform-log ,*package* ,level ,fmt-str ,@args)) | ||
| 75 | |||
| 76 | (defmacro deflevel (name value) | ||
| 77 | `(progn | ||
| 78 | (register-level ,name ,value) | ||
| 79 | (defmacro ,(intern (concatenate 'string "LOG-" (symbol-name name))) (fmt-str &rest args) | ||
| 80 | `(p ,,name ,fmt-str ,@args)))) | ||
| 81 | |||
| 82 | (deflevel :error 700) | ||
| 83 | (deflevel :warn 600) | ||
| 84 | (deflevel :info 500) | ||
| 85 | (deflevel :debug 400) | ||
diff --git a/src/main.lisp b/src/main.lisp new file mode 100644 index 0000000..af88fe6 --- /dev/null +++ b/src/main.lisp | |||
| @@ -0,0 +1,354 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/main | ||
| 4 | (:nicknames :ukkoclot) | ||
| 5 | (:use :c2cl :ukkoclot/bot :ukkoclot/config :ukkoclot/inline-bots :ukkoclot/log :ukkoclot/tg-types) | ||
| 6 | (:import-from :anaphora :aand :awhen :it) | ||
| 7 | (:import-from :ukkoclot/bot :make-bot :bot-power-on) | ||
| 8 | (:import-from :ukkoclot/db :with-db) | ||
| 9 | (:import-from :ukkoclot/strings :escape-xml :is-tg-whitespace-str :starts-with :starts-with-ignore-case) | ||
| 10 | (:local-nicknames | ||
| 11 | (:jzon :com.inuoe.jzon)) | ||
| 12 | (:export :main)) | ||
| 13 | (in-package :ukkoclot/main) | ||
| 14 | |||
| 15 | (defvar *in-prod* t) | ||
| 16 | |||
| 17 | (defmacro reporty ((evt) &body body) | ||
| 18 | `(if *in-prod* | ||
| 19 | (handler-case (progn ,@body) | ||
| 20 | (error (err) (report-error bot ,evt err))) | ||
| 21 | (progn ,@body))) | ||
| 22 | |||
| 23 | (defun main () | ||
| 24 | (unwind-protect | ||
| 25 | (let ((config (config-load #P"config.default.lisp"))) | ||
| 26 | (config-merge config #P"config.lisp") | ||
| 27 | (log-info "Starting up ~A" (config-bot-name config)) | ||
| 28 | (with-db (db (config-db-path config)) | ||
| 29 | (let ((bot (make-bot config db))) | ||
| 30 | ;; TODO: Catch fatal errors & report them | ||
| 31 | (wrapped-main bot config)))) | ||
| 32 | (log-info "We're done!"))) | ||
| 33 | |||
| 34 | (defun wrapped-main (bot config) | ||
| 35 | (send-message bot :chat-id (config-dev-group config) :text "Initializing...") | ||
| 36 | (set-my-name bot :name (config-bot-name config)) | ||
| 37 | (let ((gup-offset 0)) | ||
| 38 | (loop while (bot-power-on bot) do | ||
| 39 | (let ((updates (get-updates bot :timeout 60 :offset gup-offset))) | ||
| 40 | (loop for update across updates do | ||
| 41 | (unwind-protect | ||
| 42 | (progn | ||
| 43 | (awhen (update-message update) | ||
| 44 | (reporty (it) | ||
| 45 | (on-message bot it))) | ||
| 46 | (awhen (update-callback-query update) | ||
| 47 | (reporty (it) | ||
| 48 | (on-callback-query bot it)))) | ||
| 49 | (setf gup-offset (1+ (update-update-id update))))))) | ||
| 50 | ;; One last getUpdates to make sure offset is stored on server | ||
| 51 | (get-updates bot :timeout 0 :limit 1 :offset gup-offset)) | ||
| 52 | (send-message bot :chat-id (config-dev-group config) :text "Shutting down...")) | ||
| 53 | |||
| 54 | (defun on-callback-query (bot cb) | ||
| 55 | (let ((data (callback-query-data cb))) | ||
| 56 | (cond ((and data | ||
| 57 | (starts-with data "bbl:") | ||
| 58 | (= (user-id (callback-query-from cb)) | ||
| 59 | (config-owner (bot-config bot)))) | ||
| 60 | (let ((bot-id (read-from-string data t nil :start 4))) | ||
| 61 | (blacklist-inline-bot bot bot-id)) | ||
| 62 | (awhen (callback-query-message cb) | ||
| 63 | (delete-message bot | ||
| 64 | :chat-id (message-chat-id it) | ||
| 65 | :message-id (message-id it))) | ||
| 66 | (answer-callback-query bot | ||
| 67 | :callback-query-id (callback-query-id cb) | ||
| 68 | :text "OK")) | ||
| 69 | ((and data | ||
| 70 | (starts-with data "bwl:") | ||
| 71 | (= (user-id (callback-query-from cb)) | ||
| 72 | (config-owner (bot-config bot)))) | ||
| 73 | (let ((bot-id (read-from-string data t nil :start 4))) | ||
| 74 | (whitelist-inline-bot bot bot-id)) | ||
| 75 | (awhen (callback-query-message cb) | ||
| 76 | (delete-message bot | ||
| 77 | :chat-id (message-chat-id it) | ||
| 78 | :message-id (message-id it))) | ||
| 79 | (answer-callback-query bot | ||
| 80 | :callback-query-id (callback-query-id cb) | ||
| 81 | :text "OK")) | ||
| 82 | (t | ||
| 83 | (log-info "Unrecognised callback query data: ~A" data) | ||
| 84 | (answer-callback-query bot | ||
| 85 | :callback-query-id (callback-query-id cb) | ||
| 86 | :text "Unallowed callback query, don't press the button again" | ||
| 87 | :show-alert t))))) | ||
| 88 | |||
| 89 | |||
| 90 | (defun on-message (bot msg) | ||
| 91 | (block nil | ||
| 92 | (awhen (message-via-bot msg) | ||
| 93 | (unless (on-inline-bot bot msg it) | ||
| 94 | (return))) | ||
| 95 | |||
| 96 | (awhen (message-text msg) | ||
| 97 | (on-text-message bot msg it)) | ||
| 98 | |||
| 99 | (awhen (message-new-chat-members msg) | ||
| 100 | (loop for new-chat-member across it do | ||
| 101 | (on-new-member bot msg new-chat-member))))) | ||
| 102 | |||
| 103 | (defun on-new-member (bot msg new-member) | ||
| 104 | ;; TODO: Rule 11 no hating on cats on bot entry | ||
| 105 | ;; TODO: Rule 10 have fun and enjoy your time on user entry | ||
| 106 | (if (= (user-id new-member) (bot-id bot)) | ||
| 107 | nil | ||
| 108 | (send-message bot | ||
| 109 | :chat-id (message-chat-id msg) | ||
| 110 | :text (concatenate 'string "Hello there, " (user-format-name new-member) "! Be on your bestest behaviour now!!") | ||
| 111 | :parse-mode "HTML" | ||
| 112 | :reply-parameters | ||
| 113 | (make-reply-parameters | ||
| 114 | :allow-sending-without-reply t | ||
| 115 | :message-id (message-id msg) | ||
| 116 | :chat-id (message-chat-id msg))))) | ||
| 117 | |||
| 118 | (defun is-bad-text (text) | ||
| 119 | ;; TODO: | ||
| 120 | nil) | ||
| 121 | |||
| 122 | (defun on-text-message (bot msg text) | ||
| 123 | (block nil | ||
| 124 | (when (is-bad-text text) | ||
| 125 | ;; TODO: Delete message, mute & warn user | ||
| 126 | ;; 0 current warns: 5 minute mute, +1 warn | ||
| 127 | ;; 1 current warn : 10 minute mute, +1 warn | ||
| 128 | ;; 2 current warns: 30 minute mute, +1 warn | ||
| 129 | ;; 3 current warns: 1 hour mute, +1 warn | ||
| 130 | ;; 4 current warns: 1 day mute, +1 warn | ||
| 131 | ;; 5 current warns: Ban | ||
| 132 | ;; | ||
| 133 | ;; warn gets removed after a month of no warns | ||
| 134 | (return)) | ||
| 135 | |||
| 136 | (awhen (message-entities msg) | ||
| 137 | (loop for entity across it | ||
| 138 | when (and (eq (message-entity-type entity) :bot-command) | ||
| 139 | (= (message-entity-offset entity) 0)) | ||
| 140 | do (on-text-command bot msg text (message-entity-extract entity text)))) | ||
| 141 | |||
| 142 | (cond ((equal text ":3") | ||
| 143 | (send-message bot :chat-id (message-chat-id msg) | ||
| 144 | :text ">:3" | ||
| 145 | :reply-parameters (make-reply-parameters :message-id (message-id msg) | ||
| 146 | :chat-id (message-chat-id msg)))) | ||
| 147 | |||
| 148 | ((equal text ">:3") | ||
| 149 | (send-message bot :chat-id (message-chat-id msg) | ||
| 150 | :text "<b>>:3</b>" | ||
| 151 | :parse-mode "HTML" | ||
| 152 | :reply-parameters (make-reply-parameters | ||
| 153 | :message-id (message-id msg) | ||
| 154 | :chat-id (message-chat-id msg)))) | ||
| 155 | |||
| 156 | ((starts-with-ignore-case text "big ") | ||
| 157 | (let ((the-text (subseq text 4))) | ||
| 158 | (unless (is-tg-whitespace-str the-text) | ||
| 159 | (send-message bot | ||
| 160 | :chat-id (message-chat-id msg) | ||
| 161 | :text (concatenate 'string | ||
| 162 | "<b>" | ||
| 163 | (escape-xml (string-upcase the-text)) | ||
| 164 | "</b>") | ||
| 165 | :parse-mode "HTML" | ||
| 166 | :reply-parameters | ||
| 167 | (make-reply-parameters | ||
| 168 | :message-id (message-id msg) | ||
| 169 | :chat-id (message-chat-id msg)))))) | ||
| 170 | |||
| 171 | ((string-equal text "dio cane") | ||
| 172 | (let ((reply-msg-id (message-id msg)) | ||
| 173 | (reply-chat-id (message-chat-id msg))) | ||
| 174 | (awhen (message-reply-to-message msg) | ||
| 175 | (setf reply-msg-id (message-id it)) | ||
| 176 | (setf reply-chat-id (message-chat-id it))) | ||
| 177 | (send-message bot | ||
| 178 | :chat-id (message-chat-id msg) | ||
| 179 | :text "porco dio" | ||
| 180 | :reply-parameters | ||
| 181 | (make-reply-parameters | ||
| 182 | :message-id reply-msg-id | ||
| 183 | :chat-id reply-chat-id)))) | ||
| 184 | |||
| 185 | ((string-equal text "forgor") | ||
| 186 | (send-message bot | ||
| 187 | :chat-id (message-chat-id msg) | ||
| 188 | :text "💀" | ||
| 189 | :reply-parameters | ||
| 190 | (make-reply-parameters | ||
| 191 | :message-id (message-id msg) | ||
| 192 | :chat-id (message-chat-id msg)))) | ||
| 193 | |||
| 194 | ((string-equal text "huh") | ||
| 195 | (send-message bot | ||
| 196 | :chat-id (message-chat-id msg) | ||
| 197 | :text "idgi" | ||
| 198 | :reply-parameters | ||
| 199 | (make-reply-parameters | ||
| 200 | :message-id (message-id msg) | ||
| 201 | :chat-id (message-chat-id msg)))) | ||
| 202 | |||
| 203 | ((string= text "H") | ||
| 204 | (send-message bot | ||
| 205 | :chat-id (message-chat-id msg) | ||
| 206 | :text "<code>Randomly selected reminder that h > H.</code>" | ||
| 207 | :parse-mode "HTML" | ||
| 208 | :reply-parameters | ||
| 209 | (make-reply-parameters | ||
| 210 | :message-id (message-id msg) | ||
| 211 | :chat-id (message-chat-id msg)))) | ||
| 212 | |||
| 213 | ((string-equal text "porco dio") | ||
| 214 | (let ((reply-msg-id (message-id msg)) | ||
| 215 | (reply-chat-id (message-chat-id msg))) | ||
| 216 | (awhen (message-reply-to-message msg) | ||
| 217 | (setf reply-msg-id (message-id it)) | ||
| 218 | (setf reply-chat-id (message-chat-id it))) | ||
| 219 | (send-message bot | ||
| 220 | :chat-id (message-chat-id msg) | ||
| 221 | :text "dio cane" | ||
| 222 | :reply-parameters | ||
| 223 | (make-reply-parameters | ||
| 224 | :message-id reply-msg-id | ||
| 225 | :chat-id reply-chat-id)))) | ||
| 226 | |||
| 227 | ((starts-with-ignore-case text "say ") | ||
| 228 | (let ((the-text (subseq text 4))) | ||
| 229 | (unless (is-tg-whitespace-str the-text) | ||
| 230 | (send-message bot | ||
| 231 | :chat-id (message-chat-id msg) | ||
| 232 | :text the-text | ||
| 233 | :reply-parameters | ||
| 234 | (make-reply-parameters | ||
| 235 | :message-id (message-id msg) | ||
| 236 | :chat-id (message-chat-id msg)))))) | ||
| 237 | |||
| 238 | ((string-equal text "uwu") | ||
| 239 | (send-message bot | ||
| 240 | :chat-id (message-chat-id msg) | ||
| 241 | :text "OwO" | ||
| 242 | :reply-parameters | ||
| 243 | (make-reply-parameters | ||
| 244 | :message-id (message-id msg) | ||
| 245 | :chat-id (message-chat-id msg)))) | ||
| 246 | |||
| 247 | ((string-equal text "waow") | ||
| 248 | (let ((reply-msg-id (message-id msg)) | ||
| 249 | (reply-chat-id (message-chat-id msg))) | ||
| 250 | (awhen (message-reply-to-message msg) | ||
| 251 | (setf reply-msg-id (message-id it)) | ||
| 252 | (setf reply-chat-id (message-chat-id it))) | ||
| 253 | (send-message bot | ||
| 254 | :chat-id (message-chat-id msg) | ||
| 255 | :text "BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED BASED" | ||
| 256 | :reply-parameters | ||
| 257 | (make-reply-parameters | ||
| 258 | :message-id reply-msg-id | ||
| 259 | :chat-id reply-chat-id)))) | ||
| 260 | |||
| 261 | ((string-equal text "what") | ||
| 262 | (send-message bot | ||
| 263 | :chat-id (message-chat-id msg) | ||
| 264 | :text (with-output-to-string (s) | ||
| 265 | (if (char= (elt text 0) #\w) | ||
| 266 | (write-char #\g s) | ||
| 267 | (write-char #\G s)) | ||
| 268 | (if (char= (elt text 1) #\h) | ||
| 269 | (write-string "ood " s) | ||
| 270 | (write-string "OOD " s)) | ||
| 271 | (if (char= (elt text 2) #\a) | ||
| 272 | (write-string "gir" s) | ||
| 273 | (write-string "GIR" s)) | ||
| 274 | (if (char= (elt text 3) #\t) | ||
| 275 | (write-char #\l s) | ||
| 276 | (write-char #\L s))) | ||
| 277 | :reply-parameters | ||
| 278 | (make-reply-parameters | ||
| 279 | :message-id (message-id msg) | ||
| 280 | :chat-id (message-chat-id msg)))) | ||
| 281 | ))) | ||
| 282 | |||
| 283 | (defun simplify-cmd (bot cmd) | ||
| 284 | (let ((at-idx (position #\@ cmd))) | ||
| 285 | (if (null at-idx) | ||
| 286 | (subseq cmd 1) | ||
| 287 | (let ((username (subseq cmd (1+ at-idx))) | ||
| 288 | (my-username (bot-username bot))) | ||
| 289 | (if (equal username my-username) | ||
| 290 | (subseq cmd 1 at-idx) | ||
| 291 | nil))))) | ||
| 292 | |||
| 293 | (defun on-text-command (bot msg text cmd) | ||
| 294 | (let ((simple-cmd (simplify-cmd bot cmd))) | ||
| 295 | (log-debug "text-command: ~A AKA ~A" cmd simple-cmd) | ||
| 296 | (cond ((equal simple-cmd "chatid") | ||
| 297 | (send-message bot :chat-id (message-chat-id msg) | ||
| 298 | :text (format nil "<code>~A</code>" (message-chat-id msg)) | ||
| 299 | :parse-mode "HTML" | ||
| 300 | :reply-parameters (make-reply-parameters :message-id (message-id msg) | ||
| 301 | :chat-id (message-chat-id msg)))) | ||
| 302 | |||
| 303 | ((equal simple-cmd "msginfo") | ||
| 304 | (aand (message-reply-to-message msg) | ||
| 305 | (send-message bot :chat-id (message-chat-id msg) | ||
| 306 | ;; TODO: Text needs lot more massaging | ||
| 307 | :text (jzon:stringify (arg-encode it)) | ||
| 308 | :reply-parameters | ||
| 309 | (make-reply-parameters | ||
| 310 | :message-id (message-id msg) | ||
| 311 | :chat-id (message-chat-id msg))))) | ||
| 312 | |||
| 313 | ((equal simple-cmd "ping") | ||
| 314 | (let* ((start-time (get-internal-real-time)) | ||
| 315 | (reply (send-message bot | ||
| 316 | :chat-id (message-chat-id msg) | ||
| 317 | :text "Pong! | ||
| 318 | Send time: ..." | ||
| 319 | :reply-parameters | ||
| 320 | (make-reply-parameters | ||
| 321 | :message-id (message-id msg) | ||
| 322 | :chat-id (message-chat-id msg)))) | ||
| 323 | (end-time (get-internal-real-time)) | ||
| 324 | (time-elapsed (* (/ (- end-time start-time) internal-time-units-per-second) | ||
| 325 | 1000))) | ||
| 326 | (edit-message-text bot | ||
| 327 | :chat-id (message-chat-id msg) | ||
| 328 | :message-id (message-id reply) | ||
| 329 | :text (format nil "Pong!~2%Send time: ~Gms" time-elapsed)))) | ||
| 330 | |||
| 331 | ((and (equal simple-cmd "shutdown") | ||
| 332 | (message-from msg) | ||
| 333 | (= (user-id (message-from msg)) (config-owner (bot-config bot)))) | ||
| 334 | (setf (bot-power-on bot) nil) | ||
| 335 | (send-message bot | ||
| 336 | :chat-id (message-chat-id msg) | ||
| 337 | :text "Initialising shutdown..." | ||
| 338 | :reply-parameters | ||
| 339 | (make-reply-parameters | ||
| 340 | :allow-sending-without-reply t | ||
| 341 | :message-id (message-id msg) | ||
| 342 | :chat-id (message-chat-id msg)))) | ||
| 343 | |||
| 344 | ))) | ||
| 345 | |||
| 346 | (defun report-error (bot evt err) | ||
| 347 | (log-error "While handling ~A: ~A" evt err) | ||
| 348 | (let ((msg (format nil "<code>~A</code> while handling ~&<pre>~A</pre>" | ||
| 349 | (escape-xml (format nil "~A" err)) | ||
| 350 | (escape-xml (format nil "~A" evt))))) | ||
| 351 | (send-message bot | ||
| 352 | :chat-id (config-dev-group (bot-config bot)) | ||
| 353 | :text msg | ||
| 354 | :parse-mode "HTML"))) | ||
diff --git a/src/strings.lisp b/src/strings.lisp new file mode 100644 index 0000000..68289aa --- /dev/null +++ b/src/strings.lisp | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/strings | ||
| 4 | (:use :c2cl :iterate) | ||
| 5 | (:import-from :cl-unicode :general-category) | ||
| 6 | (:export :escape-xml :is-tg-whitespace-str :lisp->snake-case :snake->lisp-case :starts-with :starts-with-ignore-case)) | ||
| 7 | (in-package :ukkoclot/strings) | ||
| 8 | |||
| 9 | ;; These are very inefficient but I don't care until I profile | ||
| 10 | |||
| 11 | (defun escape-xml (str &optional out) | ||
| 12 | (if out | ||
| 13 | (escape-xml% str out) | ||
| 14 | (with-output-to-string (out) | ||
| 15 | (escape-xml% str out)))) | ||
| 16 | |||
| 17 | (defun escape-xml% (str out) | ||
| 18 | (loop for ch across str do | ||
| 19 | (case ch | ||
| 20 | (#\< (write-string "<" out)) | ||
| 21 | (#\> (write-string ">" out)) | ||
| 22 | (#\& (write-string "&" out)) | ||
| 23 | (#\" (write-string """ out)) | ||
| 24 | (t (write-char ch out))))) | ||
| 25 | |||
| 26 | (defun is-tg-whitespace (ch) | ||
| 27 | (let ((gc (general-category ch))) | ||
| 28 | (or (string= gc "Zs") ; Separator, space | ||
| 29 | (string= gc "Zl") ; Separator, line | ||
| 30 | (string= gc "Zp") ; Separator, paragraph | ||
| 31 | (string= gc "Cc") ; Other, control | ||
| 32 | (= (char-code ch) #x2800) ; BRAILLE PATTERN BLANK | ||
| 33 | ))) | ||
| 34 | |||
| 35 | (defun is-tg-whitespace-str (str) | ||
| 36 | (iter (for ch in-string str) | ||
| 37 | (always (is-tg-whitespace ch)))) | ||
| 38 | |||
| 39 | (defun lisp->snake-case (str) | ||
| 40 | (with-output-to-string (out) | ||
| 41 | (loop for ch across str do | ||
| 42 | (case ch | ||
| 43 | (#\- (write-char #\_ out)) | ||
| 44 | (t (write-char ch out)))))) | ||
| 45 | |||
| 46 | (defun snake->lisp-case (str) | ||
| 47 | (with-output-to-string (out) | ||
| 48 | (loop for ch across str do | ||
| 49 | (case ch | ||
| 50 | (#\_ (write-char #\- out)) | ||
| 51 | (t (write-char ch out)))))) | ||
| 52 | |||
| 53 | (defun starts-with (str prefix) | ||
| 54 | (and (> (length str) (length prefix)) | ||
| 55 | (string= str prefix :end1 (length prefix)))) | ||
| 56 | |||
| 57 | (defun starts-with-ignore-case (str prefix) | ||
| 58 | (and (> (length str) (length prefix)) | ||
| 59 | (string-equal str prefix :end1 (length prefix)))) | ||
diff --git a/src/tg-types.lisp b/src/tg-types.lisp new file mode 100644 index 0000000..1243773 --- /dev/null +++ b/src/tg-types.lisp | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (uiop:define-package :ukkoclot/tg-types | ||
| 4 | (:use) | ||
| 5 | (:use-reexport | ||
| 6 | :ukkoclot/tg-types/bot-name | ||
| 7 | :ukkoclot/tg-types/callback-query | ||
| 8 | :ukkoclot/tg-types/chat | ||
| 9 | :ukkoclot/tg-types/force-reply | ||
| 10 | :ukkoclot/tg-types/inline-keyboard-button | ||
| 11 | :ukkoclot/tg-types/inline-keyboard-markup | ||
| 12 | :ukkoclot/tg-types/link-preview-options | ||
| 13 | :ukkoclot/tg-types/message | ||
| 14 | :ukkoclot/tg-types/message-entity | ||
| 15 | :ukkoclot/tg-types/reply-parameters | ||
| 16 | :ukkoclot/tg-types/update | ||
| 17 | :ukkoclot/tg-types/user | ||
| 18 | )) | ||
diff --git a/src/tg-types/bot-name.lisp b/src/tg-types/bot-name.lisp new file mode 100644 index 0000000..385b91c --- /dev/null +++ b/src/tg-types/bot-name.lisp | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/bot-name | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:export | ||
| 6 | bot-name bot-name-p | ||
| 7 | |||
| 8 | hash->bot-name make-bot-name parse-bot-name-array | ||
| 9 | |||
| 10 | bot-name-name)) | ||
| 11 | (in-package :ukkoclot/tg-types/bot-name) | ||
| 12 | |||
| 13 | (define-tg-type bot-name | ||
| 14 | (name string)) | ||
diff --git a/src/tg-types/callback-query.lisp b/src/tg-types/callback-query.lisp new file mode 100644 index 0000000..bb1b4e7 --- /dev/null +++ b/src/tg-types/callback-query.lisp | |||
| @@ -0,0 +1,24 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/callback-query | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:use | ||
| 6 | :ukkoclot/tg-types/message | ||
| 7 | :ukkoclot/tg-types/user) | ||
| 8 | (:export | ||
| 9 | callback-query callback-query-p | ||
| 10 | |||
| 11 | hash->callback-query make-callback-query parse-callback-query-array | ||
| 12 | |||
| 13 | callback-query-id callback-query-from callback-query-message callback-query-inline-message-id | ||
| 14 | callback-query-chat-instance callback-query-data callback-query-game-short-name)) | ||
| 15 | (in-package :ukkoclot/tg-types/callback-query) | ||
| 16 | |||
| 17 | (define-tg-type callback-query | ||
| 18 | (id string) | ||
| 19 | (from user) | ||
| 20 | (message (or message null) nil) | ||
| 21 | (inline-message-id (or string null) nil) | ||
| 22 | (chat-instance string) | ||
| 23 | (data (or string null) nil) | ||
| 24 | (game-short-name (or string null) nil)) | ||
diff --git a/src/tg-types/chat.lisp b/src/tg-types/chat.lisp new file mode 100644 index 0000000..4010f7b --- /dev/null +++ b/src/tg-types/chat.lisp | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/chat | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros :ukkoclot/tg-types/parsers) | ||
| 5 | (:export | ||
| 6 | chat | ||
| 7 | make-chat | ||
| 8 | chat-p | ||
| 9 | copy-chat | ||
| 10 | chat-id | ||
| 11 | chat-type | ||
| 12 | chat-title | ||
| 13 | chat-username | ||
| 14 | chat-first-name | ||
| 15 | chat-last-name | ||
| 16 | chat-is-forum | ||
| 17 | chat-is-direct-messages | ||
| 18 | |||
| 19 | hash->chat | ||
| 20 | parse-chat-array)) | ||
| 21 | (in-package :ukkoclot/tg-types/chat) | ||
| 22 | |||
| 23 | (define-tg-type chat | ||
| 24 | (id integer) | ||
| 25 | (type keyword nil :parser tg-string->keyword) | ||
| 26 | (title (or string null) nil) | ||
| 27 | (username (or string null) nil) | ||
| 28 | (first-name (or string null) nil) | ||
| 29 | (last-name (or string null) nil) | ||
| 30 | (is-forum boolean nil) | ||
| 31 | (is-direct-messages boolean nil)) | ||
diff --git a/src/tg-types/force-reply.lisp b/src/tg-types/force-reply.lisp new file mode 100644 index 0000000..ad9d2a0 --- /dev/null +++ b/src/tg-types/force-reply.lisp | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/force-reply | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:export | ||
| 6 | force-reply | ||
| 7 | make-force-reply | ||
| 8 | force-reply-p | ||
| 9 | copy-force-reply | ||
| 10 | force-reply-force-reply | ||
| 11 | force-reply-input-field-placeholder | ||
| 12 | force-reply-selective | ||
| 13 | |||
| 14 | hash->force-reply | ||
| 15 | parse-force-reply-array)) | ||
| 16 | (in-package :ukkoclot/tg-types/force-reply) | ||
| 17 | |||
| 18 | (define-tg-type force-reply | ||
| 19 | (force-reply boolean t :skip-if-default nil) | ||
| 20 | (input-field-placeholder (or string null) nil) | ||
| 21 | (selective boolean nil)) | ||
diff --git a/src/tg-types/inline-keyboard-button.lisp b/src/tg-types/inline-keyboard-button.lisp new file mode 100644 index 0000000..3b76ade --- /dev/null +++ b/src/tg-types/inline-keyboard-button.lisp | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/inline-keyboard-button | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:export | ||
| 6 | inline-keyboard-button | ||
| 7 | make-inline-keyboard-button | ||
| 8 | inline-keyboard-button-p | ||
| 9 | copy-inline-keyboard-button | ||
| 10 | inline-keyboard-button-text | ||
| 11 | inline-keyboard-button-url | ||
| 12 | inline-keyboard-button-callback-data | ||
| 13 | inline-keyboard-button-switch-inline-query | ||
| 14 | inline-keyboard-button-switch-inline-query-current-chat | ||
| 15 | inline-keyboard-button-pay | ||
| 16 | |||
| 17 | hash->inline-keyboard-button | ||
| 18 | parse-inline-keyboard-button-array)) | ||
| 19 | (in-package :ukkoclot/tg-types/inline-keyboard-button) | ||
| 20 | |||
| 21 | (define-tg-type inline-keyboard-button | ||
| 22 | (text string) | ||
| 23 | (url (or string null) nil) | ||
| 24 | (callback-data string) | ||
| 25 | ;; TODO: (web-app (or web-app-info null) nil) | ||
| 26 | ;; TODO: (login-url (or login-url null) nil) | ||
| 27 | (switch-inline-query (or string null) nil) | ||
| 28 | (switch-inline-query-current-chat (or string null) nil) | ||
| 29 | ;; TODO: (switch-inline-query-chosen-chat (or switch-inline-query-chosen-chat null) nil) | ||
| 30 | ;; TODO: (copy-text (or copy-text-button null) nil) | ||
| 31 | ;; TODO: (callback-game (or callback-game null) nil) | ||
| 32 | (pay boolean nil)) | ||
diff --git a/src/tg-types/inline-keyboard-markup.lisp b/src/tg-types/inline-keyboard-markup.lisp new file mode 100644 index 0000000..1f17f6c --- /dev/null +++ b/src/tg-types/inline-keyboard-markup.lisp | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/inline-keyboard-markup | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/inline-keyboard-button :ukkoclot/tg-types/macros) | ||
| 5 | (:export | ||
| 6 | inline-keyboard-markup | ||
| 7 | make-inline-keyboard-markup | ||
| 8 | inline-keyboard-markup-p | ||
| 9 | copy-inline-keyboard-markup | ||
| 10 | inline-keyboard-markup-inline-keyboard | ||
| 11 | |||
| 12 | hash->inline-keyboard-markup | ||
| 13 | parse-inline-keyboard-markup-array)) | ||
| 14 | (in-package :ukkoclot/tg-types/inline-keyboard-markup) | ||
| 15 | |||
| 16 | (define-tg-type inline-keyboard-markup | ||
| 17 | (inline-keyboard (array (array inline-keyboard-button)))) | ||
diff --git a/src/tg-types/link-preview-options.lisp b/src/tg-types/link-preview-options.lisp new file mode 100644 index 0000000..66b7d83 --- /dev/null +++ b/src/tg-types/link-preview-options.lisp | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/link-preview-options | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:export | ||
| 6 | link-preview-options | ||
| 7 | make-link-preview-options | ||
| 8 | link-preview-options-p | ||
| 9 | copy-link-preview-options | ||
| 10 | link-preview-options-is-disabled | ||
| 11 | link-preview-options-url | ||
| 12 | link-preview-options-prefer-small-media | ||
| 13 | link-preview-options-prefer-large-media | ||
| 14 | link-preview-options-show-above-text | ||
| 15 | |||
| 16 | hash->link-preview-options | ||
| 17 | parse-link-preview-options-array)) | ||
| 18 | (in-package :ukkoclot/tg-types/link-preview-options) | ||
| 19 | |||
| 20 | (define-tg-type link-preview-options | ||
| 21 | (is-disabled boolean nil) | ||
| 22 | (url (or string null) nil) | ||
| 23 | (prefer-small-media boolean nil) | ||
| 24 | (prefer-large-media boolean nil) | ||
| 25 | (show-above-text boolean nil)) | ||
diff --git a/src/tg-types/macros.lisp b/src/tg-types/macros.lisp new file mode 100644 index 0000000..668df17 --- /dev/null +++ b/src/tg-types/macros.lisp | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/macros | ||
| 4 | (:use :c2cl) | ||
| 5 | (:import-from :ukkoclot/bot/impl :arg-encode :bot :do-call :will-arg-encode) | ||
| 6 | (:import-from :ukkoclot/hash-tables :gethash-lazy) | ||
| 7 | (:import-from :ukkoclot/strings :lisp->snake-case) | ||
| 8 | (:export :define-tg-method :define-tg-type)) | ||
| 9 | (in-package :ukkoclot/tg-types/macros) | ||
| 10 | |||
| 11 | (eval-when (:compile-toplevel :load-toplevel :execute) | ||
| 12 | (defstruct (field (:constructor make-field%)) name type default skip-if-default (parser 'identity)) | ||
| 13 | |||
| 14 | (defparameter +unique+ (gensym)) | ||
| 15 | |||
| 16 | (defun make-field (name type &optional (default +unique+) &key (parser 'identity) (skip-if-default (not (eq default +unique+)))) | ||
| 17 | (let ((default (if (eq default +unique+) | ||
| 18 | (list 'error (format nil "No value given for ~A" name)) | ||
| 19 | default))) | ||
| 20 | (make-field% :name name | ||
| 21 | :type type | ||
| 22 | :default default | ||
| 23 | :skip-if-default skip-if-default | ||
| 24 | :parser parser))) | ||
| 25 | |||
| 26 | (defun parse-field-specs (field-specs) | ||
| 27 | (loop for field-spec in field-specs | ||
| 28 | collect (apply #'make-field field-spec))) | ||
| 29 | |||
| 30 | (defun field-hash-key (field) | ||
| 31 | (string-downcase (lisp->snake-case (symbol-name (field-name field))))) | ||
| 32 | |||
| 33 | (defun field-accessor (struc-name field) | ||
| 34 | (intern (concatenate 'string (symbol-name struc-name) "-" (symbol-name (field-name field))))) | ||
| 35 | |||
| 36 | (defun field->defun-spec (field) | ||
| 37 | (list (field-name field) (field-default field))) | ||
| 38 | |||
| 39 | (defun field->format-arg (field name struc) | ||
| 40 | `(',(field-name field) (,(field-accessor name field) ,struc))) | ||
| 41 | |||
| 42 | (defun field->ftype-spec (field) | ||
| 43 | (list (intern (symbol-name (field-name field)) :keyword) (field-type field))) | ||
| 44 | |||
| 45 | (defun field->gethash-spec (field hash-table-sym) | ||
| 46 | (let ((hash-key (field-hash-key field))) | ||
| 47 | (list 'gethash-lazy hash-key hash-table-sym (field-default field)))) | ||
| 48 | |||
| 49 | (defun field->sethash-spec (field name struc hash-table-sym) | ||
| 50 | (let ((hash-key (field-hash-key field)) | ||
| 51 | (skip-if-default (field-skip-if-default field)) | ||
| 52 | (default (field-default field))) | ||
| 53 | (if skip-if-default | ||
| 54 | (let ((tmpsym (gensym "TMP"))) | ||
| 55 | `(let ((,tmpsym (,(field-accessor name field) ,struc))) | ||
| 56 | (unless (equal ,tmpsym ,default) | ||
| 57 | (setf (gethash ,hash-key ,hash-table-sym) ,tmpsym)))) | ||
| 58 | `(setf (gethash ,hash-key ,hash-table-sym) (,(field-accessor name field) ,struc))))) | ||
| 59 | |||
| 60 | (defun field->let-gethash-spec (field hash-table-sym) | ||
| 61 | (list (field-name field) | ||
| 62 | (list 'funcall | ||
| 63 | (list 'function (field-parser field)) | ||
| 64 | (field->gethash-spec field hash-table-sym)))) | ||
| 65 | |||
| 66 | (defun field->make-spec (field) | ||
| 67 | (list (intern (symbol-name (field-name field)) :keyword) | ||
| 68 | (field-name field))) | ||
| 69 | |||
| 70 | (defun field->struct-spec (field) | ||
| 71 | (list (field-name field) (field-default field) :type (field-type field)))) | ||
| 72 | |||
| 73 | ;; TODO: Automatically derive path from name | ||
| 74 | ;; TODO: Automatically derive mapfn from type | ||
| 75 | ;; TODO: Skip values that are already their defaults | ||
| 76 | (defmacro define-tg-method ( | ||
| 77 | (name type path mapfn &optional (method :POST)) | ||
| 78 | &body field-specs) | ||
| 79 | (let ((fields (parse-field-specs field-specs)) | ||
| 80 | (args-plist (gensym "ARGS-PLIST-")) | ||
| 81 | (bot (gensym "BOT-"))) | ||
| 82 | `(progn | ||
| 83 | (declaim (ftype (function (bot &key ,@(loop for field in fields | ||
| 84 | collect (field->ftype-spec field))) | ||
| 85 | ,type) | ||
| 86 | ,name)) | ||
| 87 | (defun ,name (,bot &rest ,args-plist &key ,@(loop for field in fields collect (field->defun-spec field))) | ||
| 88 | (declare ,@(loop for field in fields collect (list 'ignore (field-name field)))) | ||
| 89 | (do-call ,bot ,method ,path ,mapfn ,args-plist))))) | ||
| 90 | |||
| 91 | (defmacro define-tg-type (name &body field-specs) | ||
| 92 | (let* ((fields (parse-field-specs field-specs)) | ||
| 93 | (revfields (reverse fields)) | ||
| 94 | (make-name (intern (concatenate 'string "MAKE-" (symbol-name name)))) | ||
| 95 | (hash->name (intern (concatenate 'string "HASH->" (symbol-name name)))) | ||
| 96 | (parse-name-array (intern (concatenate 'string "PARSE-" (symbol-name name) "-ARRAY"))) | ||
| 97 | (printer (gensym (concatenate 'string "PRINT-" (symbol-name name)))) | ||
| 98 | (hash (gensym "HASH-")) | ||
| 99 | (array (gensym "ARRAY-")) | ||
| 100 | (struc (gensym (symbol-name name))) | ||
| 101 | (stream (gensym "STREAM")) | ||
| 102 | (depth (gensym "DEPTH")) | ||
| 103 | (pprint-args (gensym "PPRINT-ARGS"))) | ||
| 104 | `(progn | ||
| 105 | (defstruct (,name (:print-function ,printer)) | ||
| 106 | ,@(loop for field in fields | ||
| 107 | collect (field->struct-spec field))) | ||
| 108 | (defun ,printer (,struc ,stream ,depth) | ||
| 109 | (declare (ignore ,depth)) | ||
| 110 | (let (,pprint-args) | ||
| 111 | ,@(loop for field in revfields | ||
| 112 | collecting | ||
| 113 | (if (field-skip-if-default field) | ||
| 114 | `(let ((value (,(field-accessor name field) ,struc))) | ||
| 115 | (unless (equal value ,(field-default field)) | ||
| 116 | (setf ,pprint-args (list* ',(field-name field) value ,pprint-args)))) | ||
| 117 | `(setf ,pprint-args (list* ',(field-name field) (,(field-accessor name field) ,struc) ,pprint-args)))) | ||
| 118 | (format ,stream "~A~<[~;~@{~_~1I~W = ~W~^, ~}~;]~:>" ',name ,pprint-args))) | ||
| 119 | (defun ,hash->name (,hash) | ||
| 120 | (when ,hash | ||
| 121 | (let ,(loop for field in fields | ||
| 122 | collect (field->let-gethash-spec field hash)) | ||
| 123 | (,make-name ,@(loop for field in fields | ||
| 124 | append (field->make-spec field)))))) | ||
| 125 | (defmethod arg-encode ((,struc ,name)) | ||
| 126 | (let ((,hash (make-hash-table))) | ||
| 127 | ,@(loop for field in fields | ||
| 128 | collect (field->sethash-spec field name struc hash)) | ||
| 129 | ,hash)) | ||
| 130 | (defmethod will-arg-encode ((,struc ,name)) | ||
| 131 | t) | ||
| 132 | (defun ,parse-name-array (,array) | ||
| 133 | (when ,array | ||
| 134 | (map 'vector #',hash->name ,array)))))) | ||
diff --git a/src/tg-types/message-entity.lisp b/src/tg-types/message-entity.lisp new file mode 100644 index 0000000..fcabcce --- /dev/null +++ b/src/tg-types/message-entity.lisp | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/message-entity | ||
| 4 | (:use :c2cl :iterate :ukkoclot/tg-types/macros :ukkoclot/tg-types/parsers :ukkoclot/tg-types/user) | ||
| 5 | (:export | ||
| 6 | message-entity | ||
| 7 | make-message-entity | ||
| 8 | message-entity-p | ||
| 9 | copy-message-entity | ||
| 10 | message-entity-type | ||
| 11 | message-entity-offset | ||
| 12 | message-entity-length | ||
| 13 | message-entity-url | ||
| 14 | message-entity-user | ||
| 15 | message-entity-language | ||
| 16 | message-entity-custom-emoji-id | ||
| 17 | |||
| 18 | hash->message-entity | ||
| 19 | message-entity-extract | ||
| 20 | parse-message-entity-array)) | ||
| 21 | (in-package :ukkoclot/tg-types/message-entity) | ||
| 22 | |||
| 23 | (define-tg-type message-entity | ||
| 24 | (type keyword nil :parser tg-string->keyword) | ||
| 25 | (offset integer) | ||
| 26 | (length integer) | ||
| 27 | (url (or string null) nil) | ||
| 28 | (user (or user null) nil) | ||
| 29 | (language (or string null) nil) | ||
| 30 | (custom-emoji-id (or string null) nil)) | ||
| 31 | |||
| 32 | (unless (= char-code-limit #x110000) | ||
| 33 | (error "Some UTF-16 fuckery assumes that system chars are UTF-32")) | ||
| 34 | |||
| 35 | (defun utf16-width (ch) | ||
| 36 | (if (< (char-code ch) #x10000) | ||
| 37 | 1 | ||
| 38 | 2)) | ||
| 39 | |||
| 40 | (defun message-entity-extract (entity text) | ||
| 41 | (with-slots (length offset) entity | ||
| 42 | (if (= length 0) | ||
| 43 | "" | ||
| 44 | (let* ((start (iterate | ||
| 45 | (with curr-idx16 = 0) | ||
| 46 | (for ch in-string text with-index curr-idx32) | ||
| 47 | (for curr-width = (utf16-width ch)) | ||
| 48 | (when (or (= curr-idx16 offset) | ||
| 49 | (> (+ curr-idx16 curr-width) offset)) | ||
| 50 | (return curr-idx32)) | ||
| 51 | (setq curr-idx16 (+ curr-idx16 curr-width)) | ||
| 52 | (finally (return (length text))))) | ||
| 53 | (end (iterate | ||
| 54 | (with curr-len16 = 0) | ||
| 55 | (for ch in-string text from start with-index curr-idx32) | ||
| 56 | (for curr-width = (utf16-width ch)) | ||
| 57 | (when (>= curr-len16 length) | ||
| 58 | (return curr-idx32)) | ||
| 59 | (setq curr-len16 (+ curr-len16 curr-width)) | ||
| 60 | (finally (return (length text)))))) | ||
| 61 | (subseq text start end))))) | ||
diff --git a/src/tg-types/message.lisp b/src/tg-types/message.lisp new file mode 100644 index 0000000..fee0734 --- /dev/null +++ b/src/tg-types/message.lisp | |||
| @@ -0,0 +1,168 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/message | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros | ||
| 5 | |||
| 6 | :ukkoclot/tg-types/chat | ||
| 7 | :ukkoclot/tg-types/message-entity | ||
| 8 | :ukkoclot/tg-types/user) | ||
| 9 | (:export | ||
| 10 | message | ||
| 11 | make-message | ||
| 12 | message-p | ||
| 13 | copy-message | ||
| 14 | message-message-id | ||
| 15 | message-message-thread-id | ||
| 16 | message-from | ||
| 17 | message-sender-boost-count | ||
| 18 | message-sender-business-bot | ||
| 19 | message-date | ||
| 20 | message-business-connection-id | ||
| 21 | message-chat | ||
| 22 | message-is-topic-message | ||
| 23 | message-is-automatic-forward | ||
| 24 | message-reply-to-message | ||
| 25 | message-reply-to-checklist-task-id | ||
| 26 | message-via-bot | ||
| 27 | message-edit-date | ||
| 28 | message-has-protected-content | ||
| 29 | message-is-from-offline | ||
| 30 | message-is-paid-post | ||
| 31 | message-media-group-id | ||
| 32 | message-author-signature | ||
| 33 | message-paid-star-count | ||
| 34 | message-text | ||
| 35 | message-entities | ||
| 36 | message-effect-id | ||
| 37 | message-caption | ||
| 38 | message-show-caption-above-media | ||
| 39 | message-has-media-spoiler | ||
| 40 | message-new-chat-members | ||
| 41 | message-new-chat-title | ||
| 42 | message-delete-chat-photo | ||
| 43 | message-group-chat-created | ||
| 44 | message-supergroup-chat-created | ||
| 45 | message-channel-chat-created | ||
| 46 | message-migrate-to-chat-id | ||
| 47 | message-migrate-from-chat-id | ||
| 48 | message-pinned-message | ||
| 49 | message-connected-website | ||
| 50 | |||
| 51 | hash->message | ||
| 52 | message-id | ||
| 53 | message-chat-id | ||
| 54 | message-thread-id | ||
| 55 | parse-message-array)) | ||
| 56 | (in-package :ukkoclot/tg-types/message) | ||
| 57 | |||
| 58 | ;; If this is a MaybeInaccessibleMessage date will be 0 if this is inaccessible | ||
| 59 | (define-tg-type message | ||
| 60 | (message-id integer) | ||
| 61 | (message-thread-id (or integer null) nil) | ||
| 62 | ;; (direct-messages-topic (or direct-messages-topic null) nil) | ||
| 63 | (from (or user null) nil :parser hash->user) | ||
| 64 | ;; (sender-chat (or chat null) nil) | ||
| 65 | (sender-boost-count (or integer null) nil) | ||
| 66 | (sender-business-bot (or user null) nil :parser hash->user) | ||
| 67 | (date integer) | ||
| 68 | (business-connection-id (or string null) nil) | ||
| 69 | (chat chat nil :parser hash->chat) | ||
| 70 | ;; (forward-origin (or message-origin null) nil) | ||
| 71 | (is-topic-message boolean nil) | ||
| 72 | (is-automatic-forward boolean nil) | ||
| 73 | (reply-to-message (or message null) nil :parser hash->message) | ||
| 74 | ;; (external-reply (or external-reply-info null) nil) | ||
| 75 | ;; (quote (or text-quote null) nil) | ||
| 76 | ;; (reply-to-story (or story null) nil) | ||
| 77 | (reply-to-checklist-task-id (or integer null) nil) | ||
| 78 | (via-bot (or user null) nil :parser hash->user) | ||
| 79 | (edit-date (or integer null) nil) | ||
| 80 | (has-protected-content boolean nil) | ||
| 81 | (is-from-offline boolean nil) | ||
| 82 | (is-paid-post boolean nil) | ||
| 83 | (media-group-id (or string null) nil) | ||
| 84 | (author-signature (or string null) nil) | ||
| 85 | (paid-star-count (or string null) nil) | ||
| 86 | (text (or string null) nil) | ||
| 87 | (entities (or (array message-entity) null) nil :parser parse-message-entity-array) | ||
| 88 | ;; (link-preview-options (or link-preview-options null) nil) | ||
| 89 | ;; (suggested-post-info (or suggested-post-info null) nil) | ||
| 90 | (effect-id (or string null) nil) | ||
| 91 | ;; (animation (or animation null) nil) | ||
| 92 | ;; (audio (or audio null) nil) | ||
| 93 | ;; (document (or document null) nil) | ||
| 94 | ;; (paid-media (or paid-media-info null) nil) | ||
| 95 | ;; (photo (or (array photo-size) null) nil) | ||
| 96 | ;; (sticker (or sticker null) nil) | ||
| 97 | ;; (story (or story null) nil) | ||
| 98 | ;; (video (or video null) nil) | ||
| 99 | ;; (video-note (or video-note null) nil) | ||
| 100 | ;; (voice (or voice null) nil) | ||
| 101 | (caption (or string null) nil) | ||
| 102 | ;; (caption-entities (or (array message-entity) null) nil) | ||
| 103 | (show-caption-above-media boolean nil) | ||
| 104 | (has-media-spoiler boolean nil) | ||
| 105 | ;; (contact (or contact null) nil) | ||
| 106 | ;; (dice (or dice null) nil) | ||
| 107 | ;; (game (or game null) nil) | ||
| 108 | ;; (poll (or poll null) nil) | ||
| 109 | ;; (venue (or venue null) nil) | ||
| 110 | ;; (location (or location null) nil) | ||
| 111 | (new-chat-members (or (array user) null) nil :parser parse-user-array) | ||
| 112 | ;; (left-chat-member (or user null) nil) | ||
| 113 | (new-chat-title (or string null) nil) | ||
| 114 | ;; (new-chat-photo (or (array photo-size) null) nil) | ||
| 115 | (delete-chat-photo boolean nil) | ||
| 116 | (group-chat-created boolean nil) | ||
| 117 | (supergroup-chat-created boolean nil) | ||
| 118 | (channel-chat-created boolean nil) | ||
| 119 | ;; (message-auto-delete-timer-changed (orp message-auto-delete-timer-changed null) nil) | ||
| 120 | (migrate-to-chat-id (or integer null) nil) | ||
| 121 | (migrate-from-chat-id (or integer null) nil) | ||
| 122 | (pinned-message (or message null) nil :parser hash->message) | ||
| 123 | ;; (invoice (or invoice null) nil) | ||
| 124 | ;; (successful-payment (or successful-payment null) nil) | ||
| 125 | ;; (refunded-payment (or refunded-payment null) nil) | ||
| 126 | ;; (users-shared (or users-shared null) nil) | ||
| 127 | ;; (chat-shared (or chat-shared null) nil) | ||
| 128 | ;; (gift (or gift-info null) nil) | ||
| 129 | ;; (unique-gift (or unique-gift-info null) nil) | ||
| 130 | (connected-website (or string null) nil) | ||
| 131 | ;; (write-access-allowed (or write-access-allowed null) nil) | ||
| 132 | ;; (passport-data (or passport-data null) nil) | ||
| 133 | ;; (proximity-alert-triggered (or proximity-alert-triggered null) nil) | ||
| 134 | ;; (boost-added (or chat-boost-added null) nil) | ||
| 135 | ;; (chat-background-set (or chat-background null) nil) | ||
| 136 | ;; (checklist-tasks-added (or checklist-tasks-added null) nil) | ||
| 137 | ;; (direct-message-price-changed (or direct-message-price-changed null) nil) | ||
| 138 | ;; (forum-topic-created (or forum-topic-created null) nil) | ||
| 139 | ;; (forum-topic-edited (or forum-topic-edited null) nil) | ||
| 140 | ;; (forum-topic-closed (or forum-topic-closed null) nil) | ||
| 141 | ;; (forum-topic-reopened (or forum-topic-reopened null) nil) | ||
| 142 | ;; (general-forum-topic-hidden (or general-forum-topic-hidden null) nil) | ||
| 143 | ;; (general-forum-topic-unhidden (or general-forum-topic-unhidden null) nil) | ||
| 144 | ;; (giveaway-created (or giveaway-created null) nil) | ||
| 145 | ;; (giveaway-winners (or giveaway-winners null) nil) | ||
| 146 | ;; (giveaway-completed (or giveaway-completed null) nil) | ||
| 147 | ;; (paid-message-price-changed (or paid-message-price-changed null) nil) | ||
| 148 | ;; (suggested-post-approved (or suggested-post-approved null) nil) | ||
| 149 | ;; (suggested-post-approval-failed (or suggested-post-approval-failed null) nil) | ||
| 150 | ;; (suggested-post-declined (or suggested-post-declined null) nil) | ||
| 151 | ;; (suggested-post-paid (or suggested-post-paid null) nil) | ||
| 152 | ;; (suggested-post-refunded (or suggested-post-refunded null) nil) | ||
| 153 | ;; (video-chat-scheduled (or video-chat-scheduled null) nil) | ||
| 154 | ;; (video-chat-started (or video-chat-started null) nil) | ||
| 155 | ;; (video-chat-ended (or video-chat-ended null) nil) | ||
| 156 | ;; (video-chat-participants-invited (or video-chat-participants-invited null) nil) | ||
| 157 | ;; (web-app-data (or web-app-data null) nil) | ||
| 158 | ;; (reply-markup (or inline-keyboard-markup null) nil) | ||
| 159 | ) | ||
| 160 | |||
| 161 | (defun message-id (msg) | ||
| 162 | (message-message-id msg)) | ||
| 163 | |||
| 164 | (defun message-chat-id (msg) | ||
| 165 | (chat-id (message-chat msg))) | ||
| 166 | |||
| 167 | (defun message-thread-id (msg) | ||
| 168 | (message-message-thread-id msg)) | ||
diff --git a/src/tg-types/parsers.lisp b/src/tg-types/parsers.lisp new file mode 100644 index 0000000..0b6c4ae --- /dev/null +++ b/src/tg-types/parsers.lisp | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/parsers | ||
| 4 | (:use :c2cl :ukkoclot/strings) | ||
| 5 | (:export tg-string->keyword)) | ||
| 6 | (in-package :ukkoclot/tg-types/parsers) | ||
| 7 | |||
| 8 | (defun tg-string->keyword (str) | ||
| 9 | (intern (string-upcase (snake->lisp-case str)) :keyword)) | ||
diff --git a/src/tg-types/reply-parameters.lisp b/src/tg-types/reply-parameters.lisp new file mode 100644 index 0000000..5f0595d --- /dev/null +++ b/src/tg-types/reply-parameters.lisp | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/reply-parameters | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros :ukkoclot/tg-types/message-entity) | ||
| 5 | (:export | ||
| 6 | reply-parameters | ||
| 7 | make-reply-parameters | ||
| 8 | reply-parameters-p | ||
| 9 | copy-reply-parameters | ||
| 10 | reply-parameters-message-id | ||
| 11 | reply-parameters-chat-id | ||
| 12 | reply-parameters-allow-sending-without-reply | ||
| 13 | reply-parameters-quote | ||
| 14 | reply-parameters-quote-parse-mode | ||
| 15 | reply-parameters-quote-entities | ||
| 16 | reply-parameters-quote-position | ||
| 17 | reply-parameters-checklist-task-id | ||
| 18 | |||
| 19 | hash->reply-parameters | ||
| 20 | parse-reply-parameters-array)) | ||
| 21 | (in-package :ukkoclot/tg-types/reply-parameters) | ||
| 22 | |||
| 23 | (define-tg-type reply-parameters | ||
| 24 | (message-id integer) | ||
| 25 | (chat-id (or integer string null) nil) | ||
| 26 | ;; Technically true if on a business account but yeah right lmao | ||
| 27 | (allow-sending-without-reply boolean nil) | ||
| 28 | (quote (or string null) nil) | ||
| 29 | (quote-parse-mode (or string null) nil) | ||
| 30 | (quote-entities (or (array message-entity) null) nil) | ||
| 31 | (quote-position (or integer null) nil) | ||
| 32 | (checklist-task-id (or integer null) nil)) | ||
diff --git a/src/tg-types/update.lisp b/src/tg-types/update.lisp new file mode 100644 index 0000000..9043d54 --- /dev/null +++ b/src/tg-types/update.lisp | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/update | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros | ||
| 5 | :ukkoclot/tg-types/callback-query | ||
| 6 | :ukkoclot/tg-types/message) | ||
| 7 | (:export | ||
| 8 | update update-p | ||
| 9 | |||
| 10 | hash->update make-update parse-update-array | ||
| 11 | |||
| 12 | update-update-id update-message update-edited-message update-channel-post update-edited-channel-post | ||
| 13 | ;; update-business-connection | ||
| 14 | update-business-message update-edited-business-message | ||
| 15 | ;; update-deleted-business-messages update-message-reaction update-message-reaction-count update-inline-query | ||
| 16 | ;; update-chosen-inline-result | ||
| 17 | update-callback-query | ||
| 18 | ;; update-shipping-query update-pre-checkout-query update-poll update-poll-answer update-my-chat-member | ||
| 19 | ;; update-chat-member update-chat-join-request update-chat-boost update-removed-chat-boost | ||
| 20 | )) | ||
| 21 | (in-package :ukkoclot/tg-types/update) | ||
| 22 | |||
| 23 | (define-tg-type update | ||
| 24 | (update-id integer) | ||
| 25 | (message (or message null) nil :parser hash->message) | ||
| 26 | (edited-message (or message null) nil :parser hash->message) | ||
| 27 | (channel-post (or message null) nil :parser hash->message) | ||
| 28 | (edited-channel-post (or message null) nil :parser hash->message) | ||
| 29 | ;; (business-connection (or business-connection null) nil) | ||
| 30 | (business-message (or message null) nil :parser hash->message) | ||
| 31 | (edited-business-message (or message null) nil :parser hash->message) | ||
| 32 | ;; (deleted-business-messages (or business-messages-deleted null) nil) | ||
| 33 | ;; (message-reaction (or message-reaction-updated null) nil) | ||
| 34 | ;; (message-reaction-count (or message-reaction-count-updated null) nil) | ||
| 35 | ;; (inline-query (or inline-query null) nil) | ||
| 36 | ;; (chosen-inline-result (or chosen-inline-result null) nil) | ||
| 37 | (callback-query (or callback-query null) nil :parser hash->callback-query) | ||
| 38 | ;; (shipping-query (or shipping-query null) nil) | ||
| 39 | ;; (pre-checkout-query (or pre-checkout-query null) nil) | ||
| 40 | ;; (poll (or poll null) nil) | ||
| 41 | ;; (poll-answer (or poll-answer null) nil) | ||
| 42 | ;; (my-chat-member (or chat-member-updated null) nil) | ||
| 43 | ;; (chat-member (or chat-member-updated null) nil) | ||
| 44 | ;; (chat-join-request (or chat-join-request null) nil) | ||
| 45 | ;; (chat-boost (or chat-boost-updated null) nil) | ||
| 46 | ;; (removed-chat-boost (or chat-boost-removed) nil) | ||
| 47 | ) | ||
diff --git a/src/tg-types/user.lisp b/src/tg-types/user.lisp new file mode 100644 index 0000000..c5ed499 --- /dev/null +++ b/src/tg-types/user.lisp | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | (defpackage :ukkoclot/tg-types/user | ||
| 4 | (:use :c2cl :ukkoclot/tg-types/macros) | ||
| 5 | (:import-from :ukkoclot/strings :escape-xml) | ||
| 6 | (:export | ||
| 7 | user user-p | ||
| 8 | |||
| 9 | hash->user make-user parse-user-array user-format-name | ||
| 10 | |||
| 11 | user-id user-is-bot user-first-name user-last-name user-username user-language-code user-is-premium | ||
| 12 | user-added-to-attachment-menu user-can-join-groups user-can-read-all-group-messages user-supports-inline-queries | ||
| 13 | user-can-connect-to-business)) | ||
| 14 | (in-package :ukkoclot/tg-types/user) | ||
| 15 | |||
| 16 | (define-tg-type user | ||
| 17 | (id integer) | ||
| 18 | (is-bot boolean) | ||
| 19 | (first-name string) | ||
| 20 | (last-name (or string null) nil) | ||
| 21 | (username (or string null) nil) | ||
| 22 | (language-code (or string null) nil) | ||
| 23 | (is-premium boolean nil) | ||
| 24 | (added-to-attachment-menu boolean nil) | ||
| 25 | (can-join-groups boolean nil) | ||
| 26 | (can-read-all-group-messages boolean nil) | ||
| 27 | (supports-inline-queries boolean nil) | ||
| 28 | (can-connect-to-business boolean nil)) | ||
| 29 | |||
| 30 | (defun user-format-name% (user out) | ||
| 31 | (format out "<a href=\"tg://user?id=~A\"><i>" (user-id user)) | ||
| 32 | (escape-xml (user-first-name user) out) | ||
| 33 | (when (user-last-name user) | ||
| 34 | (write-char #\Space out) | ||
| 35 | (escape-xml (user-last-name user) out)) | ||
| 36 | (write-string "</i>" out) | ||
| 37 | |||
| 38 | (when (user-username user) | ||
| 39 | (write-string " @" out) | ||
| 40 | (escape-xml (user-username user) out)) | ||
| 41 | |||
| 42 | (format out "</a> [<code>~A</code>]" (user-id user))) | ||
| 43 | |||
| 44 | (defun user-format-name (user &optional out) | ||
| 45 | (if out | ||
| 46 | (user-format-name% user out) | ||
| 47 | (with-output-to-string (stream) | ||
| 48 | (user-format-name% user stream)))) | ||
diff --git a/ukkoclot.asd b/ukkoclot.asd new file mode 100644 index 0000000..eaada64 --- /dev/null +++ b/ukkoclot.asd | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | ;; SPDX-License-Identifier: EUPL-1.2 | ||
| 2 | ;; SPDX-FileCopyrightText: 2025 Uko Kokņevičs <perkontevs@gmail.com> | ||
| 3 | |||
| 4 | (defsystem "ukkoclot" | ||
| 5 | :class :package-inferred-system | ||
| 6 | :author "Uko Kokņevičs <perkontevs@gmail.com>" | ||
| 7 | :maintainer "Uko Kokņevičs <perkontevs@gmail.com>" | ||
| 8 | :licence "EUPL-1.2" | ||
| 9 | ;; TODO: :homepage | ||
| 10 | :version "0.0.1" | ||
| 11 | :description "ukkoclot: Ukko's shitty telegram bot" | ||
| 12 | :long-description #.(uiop:read-file-string (uiop:subpathname *load-pathname* "README.md")) | ||
| 13 | :pathname "src" | ||
| 14 | :depends-on (:ukkoclot/main) | ||
| 15 | ;; TODO: :in-order-to ((test-op (test-op ukkoclot-test))) | ||
| 16 | ) | ||
| 17 | |||
| 18 | (register-system-packages :closer-mop '(:c2cl)) | ||
| 19 | (register-system-packages :dexador '(:dex)) | ||