Compare commits
10 commits
14c28762bf
...
a9468c6970
Author | SHA1 | Date | |
---|---|---|---|
a9468c6970 | |||
2ce05d4f36 | |||
1852467535 | |||
9d3e07aa42 | |||
2e35b8cfc6 | |||
e50b31fc3f | |||
d0e802d77c | |||
54b15027a8 | |||
15ad3dfb94 | |||
c3cf86f6e9 |
49 changed files with 1344 additions and 492 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1 @@
|
||||||
.env.local
|
.env
|
||||||
|
|
|
@ -3,7 +3,9 @@
|
||||||
<option name="buildProfileId" value="dev" />
|
<option name="buildProfileId" value="dev" />
|
||||||
<option name="command" value="run" />
|
<option name="command" value="run" />
|
||||||
<option name="workingDirectory" value="file://$PROJECT_DIR$/holycow_backend" />
|
<option name="workingDirectory" value="file://$PROJECT_DIR$/holycow_backend" />
|
||||||
<envs />
|
<envs>
|
||||||
|
<env name="RUST_LOG" value="trace" />
|
||||||
|
</envs>
|
||||||
<option name="emulateTerminal" value="true" />
|
<option name="emulateTerminal" value="true" />
|
||||||
<option name="channel" value="DEFAULT" />
|
<option name="channel" value="DEFAULT" />
|
||||||
<option name="requiredFeatures" value="true" />
|
<option name="requiredFeatures" value="true" />
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<svg height="100%" id="emblematic-background" viewBox="0 0 2000 2000" width="100%">
|
<svg height="100%" id="emblematic-background" viewBox="0 0 2000 2000" width="100%">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="gradientBg" x1="0.25" x2="0.75" y1="1.0" y2="0.0">
|
<linearGradient id="gradientBg" x1="0.25" x2="0.75" y1="1.0" y2="0.0">
|
||||||
<stop offset="0%" stop-color="#3d0c65"/>
|
<stop offset="0%" stop-color="#3d0c65"/>
|
||||||
<stop offset="100%" stop-color="#221168"/>
|
<stop offset="100%" stop-color="#221168"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect fill="url(#gradientBg)" height="2000" width="2000"/>
|
<rect fill="url(#gradientBg)" height="2000" width="2000"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg height="63%" id="emblematic-icon" preserveAspectRatio="xMidYMid meet" viewBox="0 0 640 512" width="63%" x="94.72" xmlns="http://www.w3.org/2000/svg" y="94.72">
|
<svg height="63%" id="emblematic-icon" preserveAspectRatio="xMidYMid meet" viewBox="0 0 640 512" width="63%" x="94.72" xmlns="http://www.w3.org/2000/svg"
|
||||||
<!--! Font Awesome Pro 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. -->
|
y="94.72">
|
||||||
<path d="M96 224v32V416c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32V327.8c9.9 6.6 20.6 12 32 16.1V368c0 8.8 7.2 16 16 16s16-7.2 16-16V351.1c5.3 .6 10.6 .9 16 .9s10.7-.3 16-.9V368c0 8.8 7.2 16 16 16s16-7.2 16-16V343.8c11.4-4 22.1-9.4 32-16.1V416c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32V256l32 32v49.5c0 9.5 2.8 18.7 8.1 26.6L530 427c8.8 13.1 23.5 21 39.3 21c22.5 0 41.9-15.9 46.3-38l20.3-101.6c2.6-13-.3-26.5-8-37.3l-3.9-5.5V184c0-13.3-10.7-24-24-24s-24 10.7-24 24v14.4l-52.9-74.1C496 86.5 452.4 64 405.9 64H272 256 192 144C77.7 64 24 117.7 24 184v54C9.4 249.8 0 267.8 0 288v17.6c0 8 6.4 14.4 14.4 14.4C46.2 320 72 294.2 72 262.4V256 224 184c0-24.3 12.1-45.8 30.5-58.9C98.3 135.9 96 147.7 96 160v64zM560 336a16 16 0 1 1 32 0 16 16 0 1 1 -32 0zM166.6 166.6c-4.2-4.2-6.6-10-6.6-16c0-12.5 10.1-22.6 22.6-22.6H361.4c12.5 0 22.6 10.1 22.6 22.6c0 6-2.4 11.8-6.6 16l-23.4 23.4C332.2 211.8 302.7 224 272 224s-60.2-12.2-81.9-33.9l-23.4-23.4z" fill="#FFEE6F"/>
|
<!--! Font Awesome Pro 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2024 Fonticons, Inc. -->
|
||||||
</svg>
|
<path
|
||||||
|
d="M96 224v32V416c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32V327.8c9.9 6.6 20.6 12 32 16.1V368c0 8.8 7.2 16 16 16s16-7.2 16-16V351.1c5.3 .6 10.6 .9 16 .9s10.7-.3 16-.9V368c0 8.8 7.2 16 16 16s16-7.2 16-16V343.8c11.4-4 22.1-9.4 32-16.1V416c0 17.7 14.3 32 32 32h32c17.7 0 32-14.3 32-32V256l32 32v49.5c0 9.5 2.8 18.7 8.1 26.6L530 427c8.8 13.1 23.5 21 39.3 21c22.5 0 41.9-15.9 46.3-38l20.3-101.6c2.6-13-.3-26.5-8-37.3l-3.9-5.5V184c0-13.3-10.7-24-24-24s-24 10.7-24 24v14.4l-52.9-74.1C496 86.5 452.4 64 405.9 64H272 256 192 144C77.7 64 24 117.7 24 184v54C9.4 249.8 0 267.8 0 288v17.6c0 8 6.4 14.4 14.4 14.4C46.2 320 72 294.2 72 262.4V256 224 184c0-24.3 12.1-45.8 30.5-58.9C98.3 135.9 96 147.7 96 160v64zM560 336a16 16 0 1 1 32 0 16 16 0 1 1 -32 0zM166.6 166.6c-4.2-4.2-6.6-10-6.6-16c0-12.5 10.1-22.6 22.6-22.6H361.4c12.5 0 22.6 10.1 22.6 22.6c0 6-2.4 11.8-6.6 16l-23.4 23.4C332.2 211.8 302.7 224 272 224s-60.2-12.2-81.9-33.9l-23.4-23.4z"
|
||||||
|
fill="#FFEE6F"/>
|
||||||
|
</svg>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
5
Caddyfile
Normal file
5
Caddyfile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
:30002 {
|
||||||
|
reverse_proxy http://localhost:30000
|
||||||
|
reverse_proxy /api/* http://localhost:30001
|
||||||
|
reverse_proxy /telegram/webhook http://localhost:30001
|
||||||
|
}
|
287
LICENSE.txt
Normal file
287
LICENSE.txt
Normal file
|
@ -0,0 +1,287 @@
|
||||||
|
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||||
|
EUPL © the European Union 2007, 2016
|
||||||
|
|
||||||
|
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||||
|
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||||
|
other than as authorised under this Licence is prohibited (to the extent such
|
||||||
|
use is covered by a right of the copyright holder of the Work).
|
||||||
|
|
||||||
|
The Work is provided under the terms of this Licence when the Licensor (as
|
||||||
|
defined below) has placed the following notice immediately following the
|
||||||
|
copyright notice for the Work:
|
||||||
|
|
||||||
|
Licensed under the EUPL
|
||||||
|
|
||||||
|
or has expressed by any other means his willingness to license under the EUPL.
|
||||||
|
|
||||||
|
1. Definitions
|
||||||
|
|
||||||
|
In this Licence, the following terms have the following meaning:
|
||||||
|
|
||||||
|
- ‘The Licence’: this Licence.
|
||||||
|
|
||||||
|
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||||
|
Licensor under this Licence, available as Source Code and also as Executable
|
||||||
|
Code as the case may be.
|
||||||
|
|
||||||
|
- ‘Derivative Works’: the works or software that could be created by the
|
||||||
|
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||||
|
does not define the extent of modification or dependence on the Original Work
|
||||||
|
required in order to classify a work as a Derivative Work; this extent is
|
||||||
|
determined by copyright law applicable in the country mentioned in Article 15.
|
||||||
|
|
||||||
|
- ‘The Work’: the Original Work or its Derivative Works.
|
||||||
|
|
||||||
|
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||||
|
convenient for people to study and modify.
|
||||||
|
|
||||||
|
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||||
|
meant to be interpreted by a computer as a program.
|
||||||
|
|
||||||
|
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||||
|
the Work under the Licence.
|
||||||
|
|
||||||
|
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||||
|
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||||
|
|
||||||
|
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||||
|
the Work under the terms of the Licence.
|
||||||
|
|
||||||
|
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||||
|
renting, distributing, communicating, transmitting, or otherwise making
|
||||||
|
available, online or offline, copies of the Work or providing access to its
|
||||||
|
essential functionalities at the disposal of any other natural or legal
|
||||||
|
person.
|
||||||
|
|
||||||
|
2. Scope of the rights granted by the Licence
|
||||||
|
|
||||||
|
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||||
|
sublicensable licence to do the following, for the duration of copyright vested
|
||||||
|
in the Original Work:
|
||||||
|
|
||||||
|
- use the Work in any circumstance and for all usage,
|
||||||
|
- reproduce the Work,
|
||||||
|
- modify the Work, and make Derivative Works based upon the Work,
|
||||||
|
- communicate to the public, including the right to make available or display
|
||||||
|
the Work or copies thereof to the public and perform publicly, as the case may
|
||||||
|
be, the Work,
|
||||||
|
- distribute the Work or copies thereof,
|
||||||
|
- lend and rent the Work or copies thereof,
|
||||||
|
- sublicense rights in the Work or copies thereof.
|
||||||
|
|
||||||
|
Those rights can be exercised on any media, supports and formats, whether now
|
||||||
|
known or later invented, as far as the applicable law permits so.
|
||||||
|
|
||||||
|
In the countries where moral rights apply, the Licensor waives his right to
|
||||||
|
exercise his moral right to the extent allowed by law in order to make effective
|
||||||
|
the licence of the economic rights here above listed.
|
||||||
|
|
||||||
|
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||||
|
any patents held by the Licensor, to the extent necessary to make use of the
|
||||||
|
rights granted on the Work under this Licence.
|
||||||
|
|
||||||
|
3. Communication of the Source Code
|
||||||
|
|
||||||
|
The Licensor may provide the Work either in its Source Code form, or as
|
||||||
|
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||||
|
provides in addition a machine-readable copy of the Source Code of the Work
|
||||||
|
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||||
|
a notice following the copyright notice attached to the Work, a repository where
|
||||||
|
the Source Code is easily and freely accessible for as long as the Licensor
|
||||||
|
continues to distribute or communicate the Work.
|
||||||
|
|
||||||
|
4. Limitations on copyright
|
||||||
|
|
||||||
|
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||||
|
any exception or limitation to the exclusive rights of the rights owners in the
|
||||||
|
Work, of the exhaustion of those rights or of other applicable limitations
|
||||||
|
thereto.
|
||||||
|
|
||||||
|
5. Obligations of the Licensee
|
||||||
|
|
||||||
|
The grant of the rights mentioned above is subject to some restrictions and
|
||||||
|
obligations imposed on the Licensee. Those obligations are the following:
|
||||||
|
|
||||||
|
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||||
|
trademarks notices and all notices that refer to the Licence and to the
|
||||||
|
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||||
|
copy of the Licence with every copy of the Work he/she distributes or
|
||||||
|
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||||
|
notices stating that the Work has been modified and the date of modification.
|
||||||
|
|
||||||
|
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||||
|
Original Works or Derivative Works, this Distribution or Communication will be
|
||||||
|
done under the terms of this Licence or of a later version of this Licence
|
||||||
|
unless the Original Work is expressly distributed only under this version of the
|
||||||
|
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||||
|
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||||
|
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||||
|
|
||||||
|
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||||
|
Works or copies thereof based upon both the Work and another work licensed under
|
||||||
|
a Compatible Licence, this Distribution or Communication can be done under the
|
||||||
|
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||||
|
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||||
|
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||||
|
his/her obligations under this Licence, the obligations of the Compatible
|
||||||
|
Licence shall prevail.
|
||||||
|
|
||||||
|
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||||
|
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||||
|
a repository where this Source will be easily and freely available for as long
|
||||||
|
as the Licensee continues to distribute or communicate the Work.
|
||||||
|
|
||||||
|
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||||
|
trademarks, service marks, or names of the Licensor, except as required for
|
||||||
|
reasonable and customary use in describing the origin of the Work and
|
||||||
|
reproducing the content of the copyright notice.
|
||||||
|
|
||||||
|
6. Chain of Authorship
|
||||||
|
|
||||||
|
The original Licensor warrants that the copyright in the Original Work granted
|
||||||
|
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||||
|
power and authority to grant the Licence.
|
||||||
|
|
||||||
|
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||||
|
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||||
|
power and authority to grant the Licence.
|
||||||
|
|
||||||
|
Each time You accept the Licence, the original Licensor and subsequent
|
||||||
|
Contributors grant You a licence to their contributions to the Work, under the
|
||||||
|
terms of this Licence.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty
|
||||||
|
|
||||||
|
The Work is a work in progress, which is continuously improved by numerous
|
||||||
|
Contributors. It is not a finished work and may therefore contain defects or
|
||||||
|
‘bugs’ inherent to this type of development.
|
||||||
|
|
||||||
|
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||||
|
and without warranties of any kind concerning the Work, including without
|
||||||
|
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||||
|
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||||
|
copyright as stated in Article 6 of this Licence.
|
||||||
|
|
||||||
|
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||||
|
for the grant of any rights to the Work.
|
||||||
|
|
||||||
|
8. Disclaimer of Liability
|
||||||
|
|
||||||
|
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||||
|
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||||
|
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||||
|
of the Work, including without limitation, damages for loss of goodwill, work
|
||||||
|
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||||
|
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||||
|
However, the Licensor will be liable under statutory product liability laws as
|
||||||
|
far such laws apply to the Work.
|
||||||
|
|
||||||
|
9. Additional agreements
|
||||||
|
|
||||||
|
While distributing the Work, You may choose to conclude an additional agreement,
|
||||||
|
defining obligations or services consistent with this Licence. However, if
|
||||||
|
accepting obligations, You may act only on your own behalf and on your sole
|
||||||
|
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||||
|
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||||
|
for any liability incurred by, or claims asserted against such Contributor by
|
||||||
|
the fact You have accepted any warranty or additional liability.
|
||||||
|
|
||||||
|
10. Acceptance of the Licence
|
||||||
|
|
||||||
|
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||||
|
placed under the bottom of a window displaying the text of this Licence or by
|
||||||
|
affirming consent in any other similar way, in accordance with the rules of
|
||||||
|
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||||
|
acceptance of this Licence and all of its terms and conditions.
|
||||||
|
|
||||||
|
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||||
|
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||||
|
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||||
|
Distribution or Communication by You of the Work or copies thereof.
|
||||||
|
|
||||||
|
11. Information to the public
|
||||||
|
|
||||||
|
In case of any Distribution or Communication of the Work by means of electronic
|
||||||
|
communication by You (for example, by offering to download the Work from a
|
||||||
|
remote location) the distribution channel or media (for example, a website) must
|
||||||
|
at least provide to the public the information requested by the applicable law
|
||||||
|
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||||
|
stored and reproduced by the Licensee.
|
||||||
|
|
||||||
|
12. Termination of the Licence
|
||||||
|
|
||||||
|
The Licence and the rights granted hereunder will terminate automatically upon
|
||||||
|
any breach by the Licensee of the terms of the Licence.
|
||||||
|
|
||||||
|
Such a termination will not terminate the licences of any person who has
|
||||||
|
received the Work from the Licensee under the Licence, provided such persons
|
||||||
|
remain in full compliance with the Licence.
|
||||||
|
|
||||||
|
13. Miscellaneous
|
||||||
|
|
||||||
|
Without prejudice of Article 9 above, the Licence represents the complete
|
||||||
|
agreement between the Parties as to the Work.
|
||||||
|
|
||||||
|
If any provision of the Licence is invalid or unenforceable under applicable
|
||||||
|
law, this will not affect the validity or enforceability of the Licence as a
|
||||||
|
whole. Such provision will be construed or reformed so as necessary to make it
|
||||||
|
valid and enforceable.
|
||||||
|
|
||||||
|
The European Commission may publish other linguistic versions or new versions of
|
||||||
|
this Licence or updated versions of the Appendix, so far this is required and
|
||||||
|
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||||
|
versions of the Licence will be published with a unique version number.
|
||||||
|
|
||||||
|
All linguistic versions of this Licence, approved by the European Commission,
|
||||||
|
have identical value. Parties can take advantage of the linguistic version of
|
||||||
|
their choice.
|
||||||
|
|
||||||
|
14. Jurisdiction
|
||||||
|
|
||||||
|
Without prejudice to specific agreement between parties,
|
||||||
|
|
||||||
|
- any litigation resulting from the interpretation of this License, arising
|
||||||
|
between the European Union institutions, bodies, offices or agencies, as a
|
||||||
|
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||||
|
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||||
|
the Functioning of the European Union,
|
||||||
|
|
||||||
|
- any litigation arising between other parties and resulting from the
|
||||||
|
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||||
|
of the competent court where the Licensor resides or conducts its primary
|
||||||
|
business.
|
||||||
|
|
||||||
|
15. Applicable Law
|
||||||
|
|
||||||
|
Without prejudice to specific agreement between parties,
|
||||||
|
|
||||||
|
- this Licence shall be governed by the law of the European Union Member State
|
||||||
|
where the Licensor has his seat, resides or has his registered office,
|
||||||
|
|
||||||
|
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||||
|
residence or registered office inside a European Union Member State.
|
||||||
|
|
||||||
|
Appendix
|
||||||
|
|
||||||
|
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||||
|
|
||||||
|
- GNU General Public License (GPL) v. 2, v. 3
|
||||||
|
- GNU Affero General Public License (AGPL) v. 3
|
||||||
|
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||||
|
- Eclipse Public License (EPL) v. 1.0
|
||||||
|
- CeCILL v. 2.0, v. 2.1
|
||||||
|
- Mozilla Public Licence (MPL) v. 2
|
||||||
|
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||||
|
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||||
|
works other than software
|
||||||
|
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||||
|
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||||
|
Reciprocity (LiLiQ-R+).
|
||||||
|
|
||||||
|
The European Commission may update this Appendix to later versions of the above
|
||||||
|
licences without producing a new version of the EUPL, as long as they provide
|
||||||
|
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||||
|
Code from exclusive appropriation.
|
||||||
|
|
||||||
|
All other changes or additions to this Appendix require the production of a new
|
||||||
|
EUPL version.
|
16
README.md
16
README.md
|
@ -4,10 +4,20 @@
|
||||||
|
|
||||||
# Holy Cow
|
# Holy Cow
|
||||||
|
|
||||||
Telegram Mini App for Magic matches tracking
|
Telegram Mini App for match and skill level tracking among players in a group.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> [!Caution]
|
> [!Warning]
|
||||||
>
|
>
|
||||||
> This bot deliberately does not validate data.
|
> Due to time constraints, many variables in this service are hardcoded to be specific to the **Holy Cow** Telegram group.
|
||||||
|
>
|
||||||
|
> Let me know if you'd be interested in a similar thing for your play group!
|
||||||
|
>
|
||||||
|
> I'm evaluating if making a similar thing at scale could be worthwhile.
|
||||||
|
|
||||||
|
> [!Caution]
|
||||||
|
>
|
||||||
|
> This service **does not automatically validate data** by design.
|
||||||
|
>
|
||||||
|
> It is expected that match submissions will be manually checked later by the l
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
[package]
|
[package]
|
||||||
name = "holycow_backend"
|
name = "holycow_backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
authors = ["Stefano Pigozzi <me@steffo.eu>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "Backend for a Telegram Mini App for match and skill level tracking among players in a group"
|
||||||
|
repository = "https://forge.steffo.eu/unimore/tirocinio-canali-steffo-acrate"
|
||||||
|
license = "EUPL-1.2"
|
||||||
|
keywords = ["mtg", "openskill", "wenglin", "telegram", "telegram-miniapp"]
|
||||||
|
categories = []
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.93"
|
anyhow = "1.0.93"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||||
|
|
||||||
[print_schema]
|
[print_schema]
|
||||||
file = "src/schema.rs"
|
file = "src/database/schema.rs"
|
||||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||||
|
|
||||||
[migrations_directory]
|
[migrations_directory]
|
||||||
|
|
|
@ -2,5 +2,5 @@
|
||||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||||
-- changes will be added to existing projects as new migrations.
|
-- changes will be added to existing projects as new migrations.
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl REGCLASS);
|
||||||
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
||||||
|
|
|
@ -3,8 +3,6 @@
|
||||||
-- changes will be added to existing projects as new migrations.
|
-- changes will be added to existing projects as new migrations.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- Sets up a trigger for the given table to automatically set a column called
|
-- Sets up a trigger for the given table to automatically set a column called
|
||||||
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||||
-- in the modified columns)
|
-- in the modified columns)
|
||||||
|
@ -16,21 +14,23 @@
|
||||||
--
|
--
|
||||||
-- SELECT diesel_manage_updated_at('users');
|
-- SELECT diesel_manage_updated_at('users');
|
||||||
-- ```
|
-- ```
|
||||||
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl REGCLASS) RETURNS VOID AS
|
||||||
|
$$
|
||||||
BEGIN
|
BEGIN
|
||||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
EXECUTE FORMAT('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||||
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS TRIGGER AS
|
||||||
|
$$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF (
|
IF (
|
||||||
NEW IS DISTINCT FROM OLD AND
|
new IS DISTINCT FROM old AND
|
||||||
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
new.updated_at IS NOT DISTINCT FROM old.updated_at
|
||||||
) THEN
|
) THEN
|
||||||
NEW.updated_at := current_timestamp;
|
new.updated_at := CURRENT_TIMESTAMP;
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NEW;
|
RETURN new;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
CREATE TYPE wenglin_t AS (
|
CREATE TYPE wenglin_t AS
|
||||||
rating float8,
|
(
|
||||||
|
rating float8,
|
||||||
uncertainty float8
|
uncertainty float8
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE players (
|
CREATE TABLE players
|
||||||
id INTEGER GENERATED ALWAYS AS IDENTITY,
|
(
|
||||||
|
id INTEGER GENERATED ALWAYS AS IDENTITY,
|
||||||
|
|
||||||
wenglin wenglin_t NOT NULL DEFAULT ROW(25.0, 25.0 / 3),
|
wenglin wenglin_t NOT NULL DEFAULT ROW (25.0, 25.0 / 3),
|
||||||
|
|
||||||
telegram_id BIGINT,
|
telegram_id BIGINT,
|
||||||
|
|
||||||
|
@ -18,22 +20,23 @@ CREATE TYPE outcome_t AS ENUM (
|
||||||
'AWins',
|
'AWins',
|
||||||
'BWins',
|
'BWins',
|
||||||
'Tie'
|
'Tie'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE matches (
|
CREATE TABLE matches
|
||||||
id INTEGER GENERATED ALWAYS AS IDENTITY,
|
(
|
||||||
instant TIMESTAMPTZ NOT NULL DEFAULT now(),
|
id INTEGER GENERATED ALWAYS AS IDENTITY,
|
||||||
name VARCHAR,
|
instant timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
name VARCHAR,
|
||||||
|
|
||||||
player_a_id BIGINT NOT NULL,
|
player_a_id BIGINT NOT NULL,
|
||||||
player_a_wenglin_before wenglin_t NOT NULL,
|
player_a_wenglin_before wenglin_t NOT NULL,
|
||||||
player_a_wenglin_after wenglin_t NOT NULL,
|
player_a_wenglin_after wenglin_t NOT NULL,
|
||||||
|
|
||||||
player_b_id BIGINT NOT NULL,
|
player_b_id BIGINT NOT NULL,
|
||||||
player_b_wenglin_before wenglin_t NOT NULL,
|
player_b_wenglin_before wenglin_t NOT NULL,
|
||||||
player_b_wenglin_after wenglin_t NOT NULL,
|
player_b_wenglin_after wenglin_t NOT NULL,
|
||||||
|
|
||||||
outcome outcome_t NOT NULL,
|
outcome outcome_t NOT NULL,
|
||||||
|
|
||||||
CONSTRAINT match_unique_name UNIQUE (name),
|
CONSTRAINT match_unique_name UNIQUE (name),
|
||||||
CONSTRAINT not_same_player CHECK (player_a_id != player_b_id),
|
CONSTRAINT not_same_player CHECK (player_a_id != player_b_id),
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
ALTER TABLE players DROP COLUMN IF EXISTS competitive;
|
ALTER TABLE players
|
||||||
|
DROP COLUMN IF EXISTS competitive;
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
ALTER TABLE players ADD COLUMN competitive BOOLEAN NOT NULL DEFAULT FALSE;
|
ALTER TABLE players
|
||||||
|
ADD COLUMN competitive BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
ALTER TABLE matches ALTER COLUMN player_a_id TYPE BIGINT;
|
ALTER TABLE matches
|
||||||
ALTER TABLE matches ALTER COLUMN player_b_id TYPE BIGINT;
|
ALTER COLUMN player_a_id TYPE BIGINT;
|
||||||
|
ALTER TABLE matches
|
||||||
|
ALTER COLUMN player_b_id TYPE BIGINT;
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
ALTER TABLE matches ALTER COLUMN player_a_id TYPE INTEGER;
|
ALTER TABLE matches
|
||||||
ALTER TABLE matches ALTER COLUMN player_b_id TYPE INTEGER;
|
ALTER COLUMN player_a_id TYPE INTEGER;
|
||||||
|
ALTER TABLE matches
|
||||||
|
ALTER COLUMN player_b_id TYPE INTEGER;
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE players
|
||||||
|
DROP COLUMN IF EXISTS username;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE players
|
||||||
|
ADD COLUMN username bpchar UNIQUE NOT NULL;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE matches
|
||||||
|
ADD CONSTRAINT match_unique_name UNIQUE (name);
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE matches
|
||||||
|
DROP CONSTRAINT match_unique_name;
|
|
@ -1,6 +1,10 @@
|
||||||
|
use url; // Fixes unused dependency inspection.
|
||||||
|
|
||||||
micronfig::config! {
|
micronfig::config! {
|
||||||
DATABASE_URL: String,
|
DATABASE_URL: String,
|
||||||
BIND_ADDRESS: String > std::net::SocketAddr,
|
BACKEND_BIND_ADDRESS: String > std::net::SocketAddr,
|
||||||
TELEGRAM_API_KEY: String,
|
TELEGRAM_API_KEY: String,
|
||||||
TELEGRAM_WEBHOOK_URL: String > url::Url,
|
TELEGRAM_WEBHOOK_URL: String > url::Url,
|
||||||
|
TELEGRAM_NOTIFICATION_CHAT_ID: String > i64,
|
||||||
|
TELEGRAM_NOTIFICATION_TOPIC_ID?: String > i32,
|
||||||
}
|
}
|
||||||
|
|
1
holycow_backend/src/database/migrations.rs
Normal file
1
holycow_backend/src/database/migrations.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations::embed_migrations!();
|
3
holycow_backend/src/database/mod.rs
Normal file
3
holycow_backend/src/database/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod model;
|
||||||
|
pub mod migrations;
|
||||||
|
mod schema;
|
|
@ -1,15 +1,16 @@
|
||||||
use std::io::Write;
|
use crate::database::schema;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use diesel::{AsExpression, BoolExpressionMethods, FromSqlRow, Identifiable, Insertable, OptionalExtension, PgConnection, QueryDsl, QueryResult, Queryable, QueryableByName, RunQueryDsl, Selectable, SelectableHelper};
|
|
||||||
use diesel::backend::Backend;
|
use diesel::backend::Backend;
|
||||||
use diesel::deserialize::FromSql;
|
use diesel::deserialize::FromSql;
|
||||||
|
use diesel::dsl::insert_into;
|
||||||
use diesel::pg::Pg;
|
use diesel::pg::Pg;
|
||||||
use diesel::serialize::ToSql;
|
|
||||||
use diesel::sql_types as sql;
|
|
||||||
use diesel::serialize::Output as DieselOutput;
|
use diesel::serialize::Output as DieselOutput;
|
||||||
|
use diesel::serialize::{IsNull, ToSql};
|
||||||
|
use diesel::sql_types as sql;
|
||||||
use diesel::ExpressionMethods;
|
use diesel::ExpressionMethods;
|
||||||
|
use diesel::{AsExpression, BoolExpressionMethods, FromSqlRow, Identifiable, Insertable, OptionalExtension, PgConnection, QueryDsl, QueryResult, Queryable, QueryableByName, RunQueryDsl, Selectable, SelectableHelper};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::schema;
|
use std::io::Write;
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromSqlRow, AsExpression, Serialize, Deserialize)]
|
#[derive(Debug, Clone, FromSqlRow, AsExpression, Serialize, Deserialize)]
|
||||||
#[diesel(sql_type = sql::BigInt)]
|
#[diesel(sql_type = sql::BigInt)]
|
||||||
|
@ -31,6 +32,7 @@ pub struct Player {
|
||||||
pub wenglin: WengLinRating,
|
pub wenglin: WengLinRating,
|
||||||
pub telegram_id: Option<TelegramId>,
|
pub telegram_id: Option<TelegramId>,
|
||||||
pub competitive: bool,
|
pub competitive: bool,
|
||||||
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Insertable, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Insertable, Serialize, Deserialize)]
|
||||||
|
@ -38,8 +40,9 @@ pub struct Player {
|
||||||
#[diesel(check_for_backend(Pg))]
|
#[diesel(check_for_backend(Pg))]
|
||||||
pub struct PlayerI {
|
pub struct PlayerI {
|
||||||
pub wenglin: WengLinRating,
|
pub wenglin: WengLinRating,
|
||||||
pub telegram_id: TelegramId,
|
pub telegram_id: Option<TelegramId>,
|
||||||
pub competitive: bool,
|
pub competitive: bool,
|
||||||
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, Serialize, Deserialize)]
|
||||||
|
@ -70,7 +73,6 @@ pub struct Match {
|
||||||
#[diesel(table_name = schema::matches)]
|
#[diesel(table_name = schema::matches)]
|
||||||
#[diesel(check_for_backend(Pg))]
|
#[diesel(check_for_backend(Pg))]
|
||||||
pub struct MatchI {
|
pub struct MatchI {
|
||||||
pub instant: DateTime<Utc>,
|
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub player_a_id: i32,
|
pub player_a_id: i32,
|
||||||
pub player_a_wenglin_before: WengLinRating,
|
pub player_a_wenglin_before: WengLinRating,
|
||||||
|
@ -91,8 +93,7 @@ impl FromSql<sql::BigInt, Pg> for TelegramId {
|
||||||
|
|
||||||
impl FromSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
impl FromSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
||||||
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
fn from_sql(bytes: <Pg as Backend>::RawValue<'_>) -> diesel::deserialize::Result<Self> {
|
||||||
let rating = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
|
let (rating, uncertainty) = <(f64, f64) as FromSql<sql::Record<(sql::Double, sql::Double)>, Pg>>::from_sql(bytes)?;
|
||||||
let uncertainty = <f64 as FromSql<sql::Double, Pg>>::from_sql(bytes)?;
|
|
||||||
|
|
||||||
let rating = skillratings::weng_lin::WengLinRating::from((rating, uncertainty));
|
let rating = skillratings::weng_lin::WengLinRating::from((rating, uncertainty));
|
||||||
Ok(Self(rating))
|
Ok(Self(rating))
|
||||||
|
@ -120,10 +121,10 @@ impl ToSql<sql::BigInt, Pg> for TelegramId {
|
||||||
|
|
||||||
impl ToSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
impl ToSql<schema::sql_types::WenglinT, Pg> for WengLinRating {
|
||||||
fn to_sql<'b>(&'b self, out: &mut DieselOutput<'b, '_, Pg>) -> diesel::serialize::Result {
|
fn to_sql<'b>(&'b self, out: &mut DieselOutput<'b, '_, Pg>) -> diesel::serialize::Result {
|
||||||
<f64 as ToSql<sql::Double, Pg>>::to_sql(&self.0.rating, out)?;
|
diesel::serialize::WriteTuple::<(sql::Double, sql::Double)>::write_tuple(
|
||||||
<f64 as ToSql<sql::Double, Pg>>::to_sql(&self.0.uncertainty, out)?;
|
&(self.0.rating, self.0.uncertainty),
|
||||||
|
out,
|
||||||
Ok(diesel::serialize::IsNull::No)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,12 +138,54 @@ impl ToSql<schema::sql_types::OutcomeT, Pg> for Outcome {
|
||||||
}
|
}
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(diesel::serialize::IsNull::No)
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Outcome> for skillratings::Outcomes {
|
||||||
|
fn from(value: Outcome) -> Self {
|
||||||
|
match value {
|
||||||
|
Outcome::AWins => Self::WIN,
|
||||||
|
Outcome::BWins => Self::LOSS,
|
||||||
|
Outcome::Tie => Self::DRAW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WengLinRating {
|
||||||
|
pub fn human_score(&self) -> i64 {
|
||||||
|
let rating = self.0.rating;
|
||||||
|
let uncertainty = self.0.uncertainty;
|
||||||
|
log::debug!("Getting human score for: {rating:?}±{uncertainty:?}");
|
||||||
|
let uncertain = self.0.rating - self.0.uncertainty;
|
||||||
|
log::trace!("Minimum score is: {uncertain:?}");
|
||||||
|
let multiplied = uncertain * 300.0 / 5.0;
|
||||||
|
log::trace!("Multiplied score is: {multiplied:?}");
|
||||||
|
let ceiled: f64 = multiplied.ceil();
|
||||||
|
log::trace!("Ceiled score is: {ceiled:?}");
|
||||||
|
let converted: i64 = ceiled as i64;
|
||||||
|
log::debug!("Human score for {rating:?}±{uncertainty:?} is {converted:?}");
|
||||||
|
converted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
|
pub fn total(conn: &mut PgConnection) -> QueryResult<i64> {
|
||||||
|
log::debug!("Querying total amount of players...");
|
||||||
|
schema::players::table
|
||||||
|
.select(diesel::dsl::count_star())
|
||||||
|
.get_result::<i64>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all(conn: &mut PgConnection) -> QueryResult<Vec<Self>> {
|
||||||
|
log::debug!("Querying all players...");
|
||||||
|
schema::players::table
|
||||||
|
.select(Self::as_select())
|
||||||
|
.get_results::<Self>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult<Option<Self>> {
|
pub fn get_by_id(conn: &mut PgConnection, player_id: i32) -> QueryResult<Option<Self>> {
|
||||||
|
log::debug!("Querying player with id: {player_id:?}");
|
||||||
schema::players::table
|
schema::players::table
|
||||||
.select(Self::as_select())
|
.select(Self::as_select())
|
||||||
.filter(schema::players::id.eq(player_id))
|
.filter(schema::players::id.eq(player_id))
|
||||||
|
@ -151,6 +194,7 @@ impl Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_by_telegram_id(conn: &mut PgConnection, telegram_id: TelegramId) -> QueryResult<Option<Self>> {
|
pub fn get_by_telegram_id(conn: &mut PgConnection, telegram_id: TelegramId) -> QueryResult<Option<Self>> {
|
||||||
|
log::debug!("Querying player with telegram id: {telegram_id:?}");
|
||||||
schema::players::table
|
schema::players::table
|
||||||
.select(Self::as_select())
|
.select(Self::as_select())
|
||||||
.filter(schema::players::telegram_id.eq(telegram_id))
|
.filter(schema::players::telegram_id.eq(telegram_id))
|
||||||
|
@ -165,6 +209,12 @@ impl Player {
|
||||||
pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult<i64> {
|
pub fn won_count(&self, conn: &mut PgConnection) -> QueryResult<i64> {
|
||||||
Match::won_by_count(conn, self.id)
|
Match::won_by_count(conn, self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_wenglin(self, conn: &mut PgConnection, value: &WengLinRating) -> QueryResult<Self> {
|
||||||
|
diesel::update(schema::players::table.find(self.id))
|
||||||
|
.set(schema::players::wenglin.eq(value))
|
||||||
|
.get_result(conn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Match {
|
impl Match {
|
||||||
|
@ -174,6 +224,22 @@ impl Match {
|
||||||
.get_result::<i64>(conn)
|
.get_result::<i64>(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn all(conn: &mut PgConnection) -> QueryResult<Vec<Self>> {
|
||||||
|
schema::matches::table
|
||||||
|
.select(Self::as_select())
|
||||||
|
.order_by(schema::matches::instant)
|
||||||
|
.get_results::<Self>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn played_by(conn: &mut PgConnection, player_id: i32) -> QueryResult<Vec<Self>> {
|
||||||
|
schema::matches::table
|
||||||
|
.select(Self::as_select())
|
||||||
|
.or_filter(schema::matches::player_a_id.eq(player_id))
|
||||||
|
.or_filter(schema::matches::player_b_id.eq(player_id))
|
||||||
|
.order_by(schema::matches::instant.desc())
|
||||||
|
.get_results::<Self>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn played_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult<i64> {
|
pub fn played_by_count(conn: &mut PgConnection, player_id: i32) -> QueryResult<i64> {
|
||||||
schema::matches::table
|
schema::matches::table
|
||||||
.select(diesel::dsl::count_star())
|
.select(diesel::dsl::count_star())
|
||||||
|
@ -192,3 +258,19 @@ impl Match {
|
||||||
.get_result::<i64>(conn)
|
.get_result::<i64>(conn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PlayerI {
|
||||||
|
pub fn insert(&self, conn: &mut PgConnection) -> QueryResult<Player> {
|
||||||
|
insert_into(schema::players::table)
|
||||||
|
.values(self)
|
||||||
|
.get_result::<Player>(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatchI {
|
||||||
|
pub fn insert(self, conn: &mut PgConnection) -> QueryResult<Match> {
|
||||||
|
insert_into(schema::matches::table)
|
||||||
|
.values(self)
|
||||||
|
.get_result::<Match>(conn)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
// @generated automatically by Diesel CLI.
|
// @generated automatically by Diesel CLI.
|
||||||
|
|
||||||
pub mod sql_types {
|
pub mod sql_types {
|
||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "outcome_t"))]
|
#[diesel(postgres_type(name = "outcome_t"))]
|
||||||
pub struct OutcomeT;
|
pub struct OutcomeT;
|
||||||
|
|
||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "wenglin_t"))]
|
#[diesel(postgres_type(name = "wenglin_t"))]
|
||||||
pub struct WenglinT;
|
pub struct WenglinT;
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
|
@ -38,6 +38,7 @@ diesel::table! {
|
||||||
wenglin -> WenglinT,
|
wenglin -> WenglinT,
|
||||||
telegram_id -> Nullable<Int8>,
|
telegram_id -> Nullable<Int8>,
|
||||||
competitive -> Bool,
|
competitive -> Bool,
|
||||||
|
username -> Bpchar,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,170 +1,113 @@
|
||||||
use std::convert::Infallible;
|
|
||||||
use std::process::exit;
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::extract::Path;
|
use axum::Extension;
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::Json;
|
|
||||||
use diesel::{Connection, PgConnection};
|
use diesel::{Connection, PgConnection};
|
||||||
use diesel_migrations::MigrationHarness;
|
use diesel_migrations::MigrationHarness;
|
||||||
use serde::Serialize;
|
use std::convert::Infallible;
|
||||||
use teloxide::{dptree};
|
use std::process::exit;
|
||||||
use teloxide::dispatching::{DefaultKey, MessageFilterExt, UpdateFilterExt};
|
use teloxide::dispatching::DefaultKey;
|
||||||
use teloxide::error_handlers::LoggingErrorHandler;
|
use teloxide::error_handlers::LoggingErrorHandler;
|
||||||
use teloxide::types::{Message, WebAppData};
|
|
||||||
use teloxide::update_listeners::webhooks::Options;
|
use teloxide::update_listeners::webhooks::Options;
|
||||||
use crate::types::TelegramId;
|
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod schema;
|
mod database;
|
||||||
mod types;
|
mod routes;
|
||||||
|
mod telegram;
|
||||||
pub const MIGRATIONS: diesel_migrations::EmbeddedMigrations = diesel_migrations::embed_migrations!();
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<Infallible> {
|
async fn main() -> anyhow::Result<Infallible> {
|
||||||
pretty_env_logger::init();
|
pretty_env_logger::init();
|
||||||
log::trace!("Logging initialized!");
|
log::trace!("Logging initialized!");
|
||||||
|
|
||||||
log::trace!("Determining database URL...");
|
log::trace!("Determining database URL...");
|
||||||
let db = config::DATABASE_URL();
|
let db = config::DATABASE_URL();
|
||||||
log::trace!("Database URL is: {db:?}");
|
log::trace!("Database URL is: {db:?}");
|
||||||
|
|
||||||
log::trace!("Determining bind address...");
|
log::trace!("Determining bind address...");
|
||||||
let bind_address = config::BIND_ADDRESS();
|
let bind_address = config::BACKEND_BIND_ADDRESS();
|
||||||
log::trace!("Bind address is: {bind_address:?}");
|
log::trace!("Bind address is: {bind_address:?}");
|
||||||
|
|
||||||
log::trace!("Connecting to: {db:?}");
|
log::trace!("Connecting to: {db:?}");
|
||||||
let mut db = match PgConnection::establish(db) {
|
let mut db = match PgConnection::establish(db) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to connect to the PostgreSQL database: {e:#?}");
|
log::error!("Failed to connect to the PostgreSQL database: {e:#?}");
|
||||||
exit(2);
|
exit(2);
|
||||||
}
|
}
|
||||||
Ok(db) => db,
|
Ok(db) => db,
|
||||||
};
|
};
|
||||||
|
|
||||||
log::trace!("Running migrations...");
|
log::trace!("Running migrations...");
|
||||||
if let Err(e) = db.run_pending_migrations(MIGRATIONS) {
|
if let Err(e) = db.run_pending_migrations(database::migrations::MIGRATIONS) {
|
||||||
log::error!("Failed to perform migration: {e:#?}");
|
log::error!("Failed to perform migration: {e:#?}");
|
||||||
exit(2);
|
exit(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
log::trace!("Creating Telegram bot...");
|
log::trace!("Creating Telegram bot...");
|
||||||
let bot = teloxide::Bot::new(config::TELEGRAM_API_KEY());
|
let bot = teloxide::Bot::new(config::TELEGRAM_API_KEY());
|
||||||
|
|
||||||
log::trace!("Setting up webhooks...");
|
log::trace!("Setting up webhooks...");
|
||||||
let (telegram_listener, _telegram_stop, telegram_router) = teloxide::update_listeners::webhooks::axum_to_router(
|
let (telegram_listener, _telegram_stop, telegram_router) = teloxide::update_listeners::webhooks::axum_to_router(
|
||||||
bot.clone(),
|
bot.clone(),
|
||||||
Options {
|
Options {
|
||||||
address: config::BIND_ADDRESS().clone(),
|
address: config::BACKEND_BIND_ADDRESS().clone(),
|
||||||
url: config::TELEGRAM_WEBHOOK_URL().clone(),
|
url: config::TELEGRAM_WEBHOOK_URL().clone(),
|
||||||
path: "/".to_string(),
|
path: "/".to_string(),
|
||||||
certificate: None,
|
certificate: None,
|
||||||
max_connections: None,
|
max_connections: None,
|
||||||
drop_pending_updates: false,
|
drop_pending_updates: false,
|
||||||
secret_token: None,
|
secret_token: None,
|
||||||
}
|
},
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
log::trace!("Creating Axum router...");
|
log::trace!("Creating Axum router...");
|
||||||
let app = axum::Router::new()
|
let app = axum::Router::new()
|
||||||
.route("/players/holycow/:player_id/results", axum::routing::get(results_by_id_handler))
|
.route("/api/results/",
|
||||||
.route("/players/telegram/:telegram_id/results", axum::routing::get(results_by_telegram_id_handler))
|
axum::routing::get(routes::results::get_all),
|
||||||
.nest("/telegram/webhook", telegram_router)
|
)
|
||||||
;
|
.route("/api/results/holycow/:player_id",
|
||||||
|
axum::routing::get(routes::results::get_by_id),
|
||||||
log::trace!("Setting up Telegram dispatcher...");
|
)
|
||||||
let mut telegram_dispatcher = teloxide::dispatching::Dispatcher::<teloxide::Bot, anyhow::Error, DefaultKey>::builder(
|
.route("/api/results/telegram/:telegram_id",
|
||||||
bot.clone(),
|
axum::routing::get(routes::results::get_by_telegram_id),
|
||||||
Message::filter_web_app_data()
|
)
|
||||||
.endpoint(telegram_web_app_handler)
|
.route("/api/matches/",
|
||||||
|
axum::routing::get(routes::matches::get_all),
|
||||||
|
)
|
||||||
|
.route("/api/matches/",
|
||||||
|
axum::routing::post(routes::matches::post_match),
|
||||||
|
)
|
||||||
|
.route("/api/matches/holycow/:player_id",
|
||||||
|
axum::routing::post(routes::matches::get_played_by_id),
|
||||||
|
)
|
||||||
|
.nest("/telegram/webhook",
|
||||||
|
telegram_router,
|
||||||
|
)
|
||||||
|
.layer(Extension(bot.clone()))
|
||||||
|
;
|
||||||
|
|
||||||
|
log::trace!("Setting up Telegram dispatcher...");
|
||||||
|
let mut telegram_dispatcher = teloxide::dispatching::Dispatcher::<teloxide::Bot, anyhow::Error, DefaultKey>::builder(
|
||||||
|
bot.clone(),
|
||||||
|
teloxide::dptree::entry(),
|
||||||
)
|
)
|
||||||
.default_handler(|u| async move {
|
.default_handler(|u| async move {
|
||||||
log::trace!("Unhandled update: {u:#?}")
|
log::trace!("Unhandled update: {u:?}")
|
||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
log::trace!("Creating Telegram dispatcher future...");
|
log::trace!("Creating Telegram dispatcher future...");
|
||||||
let telegram_future = telegram_dispatcher.dispatch_with_listener(telegram_listener, LoggingErrorHandler::new());
|
let telegram_future = telegram_dispatcher.dispatch_with_listener(telegram_listener, LoggingErrorHandler::new());
|
||||||
|
|
||||||
log::trace!("Creating Tokio listener...");
|
log::trace!("Creating Tokio listener...");
|
||||||
let tokio_listener = tokio::net::TcpListener::bind(bind_address)
|
let tokio_listener = tokio::net::TcpListener::bind(bind_address)
|
||||||
.await
|
.await
|
||||||
.context("failed to bind listener to address")?;
|
.context("failed to bind listener to address")?;
|
||||||
|
|
||||||
log::trace!("Creating Axum server future...");
|
log::trace!("Creating Axum server future...");
|
||||||
let axum_future = axum::serve(tokio_listener, app);
|
let axum_future = axum::serve(tokio_listener, app);
|
||||||
|
|
||||||
log::info!("Running Axum server future and Telegram dispatcher future!");
|
log::info!("Running Axum server future and Telegram dispatcher future!");
|
||||||
let _ = tokio::join!(axum_future, telegram_future);
|
let _ = tokio::join!(axum_future, telegram_future);
|
||||||
|
|
||||||
log::error!("Server exited!");
|
log::error!("Server exited!");
|
||||||
exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
|
||||||
struct ResultsResponse {
|
|
||||||
played: i64,
|
|
||||||
won: i64,
|
|
||||||
rating: f64,
|
|
||||||
uncertainty: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn results(
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
player: types::Player,
|
|
||||||
) -> Result<Json<ResultsResponse>, StatusCode> {
|
|
||||||
let played = player.played_count(conn)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
let won = player.won_count(conn)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
let rating = match player.competitive {
|
|
||||||
false => 0.0,
|
|
||||||
true => player.wenglin.0.rating,
|
|
||||||
};
|
|
||||||
|
|
||||||
let uncertainty = match player.competitive {
|
|
||||||
false => 0.0,
|
|
||||||
true => player.wenglin.0.uncertainty,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(ResultsResponse {
|
|
||||||
played, won, rating, uncertainty
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[axum::debug_handler]
|
|
||||||
async fn results_by_id_handler(
|
|
||||||
Path(player_id): Path<i32>,
|
|
||||||
) -> Result<Json<ResultsResponse>, StatusCode> {
|
|
||||||
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
let player = types::Player::get_by_id(&mut conn, player_id)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
results(&mut conn, player)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[axum::debug_handler]
|
|
||||||
async fn results_by_telegram_id_handler(
|
|
||||||
Path(telegram_id): Path<TelegramId>,
|
|
||||||
) -> Result<Json<ResultsResponse>, StatusCode> {
|
|
||||||
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
let player = types::Player::get_by_telegram_id(&mut conn, telegram_id)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
|
||||||
|
|
||||||
results(&mut conn, player)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn telegram_web_app_handler(
|
|
||||||
web_app_data: WebAppData,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
log::trace!("{web_app_data:#?}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
185
holycow_backend/src/routes/matches.rs
Normal file
185
holycow_backend/src/routes/matches.rs
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
use crate::config;
|
||||||
|
use crate::database::model::{Match, MatchI, Outcome, Player, WengLinRating};
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use diesel::{Connection, PgConnection};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use skillratings::weng_lin::WengLinConfig;
|
||||||
|
use teloxide::requests::Requester;
|
||||||
|
use teloxide::types::{ChatId, MessageId, ParseMode, ThreadId};
|
||||||
|
use teloxide::Bot;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct MatchII {
|
||||||
|
name: Option<String>,
|
||||||
|
player_a: i32,
|
||||||
|
player_b: i32,
|
||||||
|
outcome: Outcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn get_all()
|
||||||
|
-> Result<Json<Vec<Match>>, StatusCode>
|
||||||
|
{
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let matches = Match::all(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn get_played_by_id(
|
||||||
|
Path(player_id): Path<i32>,
|
||||||
|
)
|
||||||
|
-> Result<Json<Vec<Match>>, StatusCode>
|
||||||
|
{
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let matches = Match::played_by(&mut conn, player_id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_to_text(player: &Player, before: &WengLinRating, after: &WengLinRating) -> String {
|
||||||
|
let name = &player.username;
|
||||||
|
let competitive = &player.competitive;
|
||||||
|
let telegram_id = &player.telegram_id.clone().map(|t| t.0);
|
||||||
|
|
||||||
|
match competitive {
|
||||||
|
false => {
|
||||||
|
match telegram_id {
|
||||||
|
None =>
|
||||||
|
format!(r#"<b>{name}</b>"#),
|
||||||
|
Some(telegram_id) =>
|
||||||
|
format!(r#"<b><a href="tg://user?id={telegram_id}">{name}</a></b>"#),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true => {
|
||||||
|
let before = before.human_score();
|
||||||
|
let after = after.human_score();
|
||||||
|
let change = after - before;
|
||||||
|
|
||||||
|
match telegram_id {
|
||||||
|
None =>
|
||||||
|
format!(r#"<b>{name}</b> ({change:+} ★)"#),
|
||||||
|
Some(telegram_id) =>
|
||||||
|
format!(r#"<b><a href="tg://user?id={telegram_id}">{name}</a></b> ({change:+} ★)"#),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_to_text(r#match: &Match, player_a: &Player, player_b: &Player) -> String {
|
||||||
|
let player_a = player_to_text(player_a, &r#match.player_a_wenglin_before, &r#match.player_a_wenglin_after);
|
||||||
|
let player_b = player_to_text(player_b, &r#match.player_b_wenglin_before, &r#match.player_b_wenglin_after);
|
||||||
|
|
||||||
|
match r#match.outcome {
|
||||||
|
Outcome::AWins => match &r#match.name {
|
||||||
|
Some(name) => format!("🔵 {player_a} ha trionfato su {player_b} in <b>{name}</b>!"),
|
||||||
|
None => format!("🔵 {player_a} ha trionfato su {player_b}!"),
|
||||||
|
},
|
||||||
|
Outcome::BWins => match &r#match.name {
|
||||||
|
Some(name) => format!("🟠 {player_a} è stato sconfitto da {player_b} in <b>{name}</b>!"),
|
||||||
|
None => format!("🟠 {player_a} è stato sconfitto da {player_b}!"),
|
||||||
|
},
|
||||||
|
Outcome::Tie => match &r#match.name {
|
||||||
|
Some(name) => format!("⚪️ {player_a} e {player_b} hanno pareggiato in <b>{name}</b>!"),
|
||||||
|
None => format!("⚪️ {player_a} e {player_b} hanno pareggiato!"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_match(
|
||||||
|
Extension(bot): Extension<Bot>,
|
||||||
|
Json(matchii): Json<MatchII>,
|
||||||
|
)
|
||||||
|
-> Result<Json<Match>, StatusCode>
|
||||||
|
{
|
||||||
|
log::debug!("New MatchII just dropped: {matchii:#?}");
|
||||||
|
let name = matchii.name;
|
||||||
|
let outcome = matchii.outcome;
|
||||||
|
|
||||||
|
log::trace!("Establishing database connection...");
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log::trace!("Finding player A's info...");
|
||||||
|
let player_a = Player::get_by_id(&mut conn, matchii.player_a)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or_else(|| StatusCode::NOT_FOUND)?;
|
||||||
|
let player_a_id = player_a.id;
|
||||||
|
let player_a_wenglin_before = player_a.wenglin.clone();
|
||||||
|
|
||||||
|
log::trace!("Finding player B's info...");
|
||||||
|
let player_b = Player::get_by_id(&mut conn, matchii.player_b)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or_else(|| StatusCode::NOT_FOUND)?;
|
||||||
|
let player_b_id = player_b.id;
|
||||||
|
let player_b_wenglin_before = player_b.wenglin.clone();
|
||||||
|
|
||||||
|
log::trace!("Calculating rating changes...");
|
||||||
|
let (player_a_wenglin_after, player_b_wenglin_after) = skillratings::weng_lin::weng_lin(
|
||||||
|
&player_a_wenglin_before.0,
|
||||||
|
&player_b_wenglin_before.0,
|
||||||
|
&outcome.into(),
|
||||||
|
&WengLinConfig::default(),
|
||||||
|
);
|
||||||
|
let player_a_wenglin_after = WengLinRating(player_a_wenglin_after);
|
||||||
|
log::trace!("A's new rating is: {player_a_wenglin_after:?}");
|
||||||
|
let player_b_wenglin_after = WengLinRating(player_b_wenglin_after);
|
||||||
|
log::trace!("B's new rating is: {player_b_wenglin_after:?}");
|
||||||
|
|
||||||
|
log::trace!("Starting database transaction...");
|
||||||
|
let (r#match, player_a, player_b) = conn.transaction(|tx| {
|
||||||
|
log::trace!("Updating A's rating...");
|
||||||
|
let player_a = player_a.update_wenglin(tx, &player_a_wenglin_after)
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
log::trace!("Updating B's rating...");
|
||||||
|
let player_b = player_b.update_wenglin(tx, &player_b_wenglin_after)
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
|
||||||
|
log::trace!("Inserting match...");
|
||||||
|
let matchi = MatchI {
|
||||||
|
name,
|
||||||
|
player_a_id,
|
||||||
|
player_a_wenglin_before,
|
||||||
|
player_a_wenglin_after,
|
||||||
|
player_b_id,
|
||||||
|
player_b_wenglin_before,
|
||||||
|
player_b_wenglin_after,
|
||||||
|
outcome,
|
||||||
|
};
|
||||||
|
let r#match = matchi.insert(tx)
|
||||||
|
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
|
||||||
|
|
||||||
|
Ok::<(Match, Player, Player), anyhow::Error>((r#match, player_a, player_b))
|
||||||
|
})
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
log::trace!("Preparing send message future...");
|
||||||
|
|
||||||
|
let chat = config::TELEGRAM_NOTIFICATION_CHAT_ID();
|
||||||
|
let chat = ChatId(*chat);
|
||||||
|
let mut send_message_future = bot.send_message(chat, match_to_text(&r#match, &player_a, &player_b));
|
||||||
|
|
||||||
|
send_message_future.parse_mode = Some(ParseMode::Html);
|
||||||
|
|
||||||
|
let topic = config::TELEGRAM_NOTIFICATION_TOPIC_ID();
|
||||||
|
if let Some(topic) = topic {
|
||||||
|
let topic = MessageId(*topic);
|
||||||
|
let topic = ThreadId(topic);
|
||||||
|
send_message_future.message_thread_id = Some(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::trace!("Sending message...");
|
||||||
|
let _message = send_message_future.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(r#match))
|
||||||
|
}
|
2
holycow_backend/src/routes/mod.rs
Normal file
2
holycow_backend/src/routes/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod results;
|
||||||
|
pub mod matches;
|
117
holycow_backend/src/routes/results.rs
Normal file
117
holycow_backend/src/routes/results.rs
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
use crate::config;
|
||||||
|
use crate::database::model;
|
||||||
|
use crate::database::model::TelegramId;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use diesel::{Connection, PgConnection};
|
||||||
|
use model::Player;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerO {
|
||||||
|
id: i32,
|
||||||
|
telegram_id: Option<TelegramId>,
|
||||||
|
username: String,
|
||||||
|
human_score: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Player> for PlayerO {
|
||||||
|
fn from(value: Player) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
telegram_id: value.telegram_id,
|
||||||
|
username: value.username,
|
||||||
|
human_score: match value.competitive {
|
||||||
|
true => Some(value.wenglin.human_score()),
|
||||||
|
false => None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn get_all()
|
||||||
|
-> Result<Json<Vec<PlayerO>>, StatusCode>
|
||||||
|
{
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let players = Player::all(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(players.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is an awful hack but idc i'm out of time
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlayerOO {
|
||||||
|
id: i32,
|
||||||
|
telegram_id: Option<TelegramId>,
|
||||||
|
username: String,
|
||||||
|
human_score: Option<i64>,
|
||||||
|
played: i64,
|
||||||
|
wins: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn get_by_id(
|
||||||
|
Path(player_id): Path<i32>,
|
||||||
|
)
|
||||||
|
-> Result<Json<PlayerOO>, StatusCode>
|
||||||
|
{
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let player = Player::get_by_id(&mut conn, player_id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let played = player.played_count(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let wins = player.won_count(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let player_o = PlayerO::from(player);
|
||||||
|
|
||||||
|
Ok(Json(PlayerOO {
|
||||||
|
id: player_o.id,
|
||||||
|
telegram_id: player_o.telegram_id,
|
||||||
|
username: player_o.username,
|
||||||
|
human_score: player_o.human_score,
|
||||||
|
played,
|
||||||
|
wins,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn get_by_telegram_id(
|
||||||
|
Path(telegram_id): Path<TelegramId>,
|
||||||
|
)
|
||||||
|
-> Result<Json<PlayerOO>, StatusCode>
|
||||||
|
{
|
||||||
|
let mut conn = PgConnection::establish(config::DATABASE_URL())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let player = Player::get_by_telegram_id(&mut conn, telegram_id)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let played = player.played_count(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let wins = player.won_count(&mut conn)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let player_o = PlayerO::from(player);
|
||||||
|
|
||||||
|
Ok(Json(PlayerOO {
|
||||||
|
id: player_o.id,
|
||||||
|
telegram_id: player_o.telegram_id,
|
||||||
|
username: player_o.username,
|
||||||
|
human_score: player_o.human_score,
|
||||||
|
played,
|
||||||
|
wins,
|
||||||
|
}))
|
||||||
|
}
|
0
holycow_backend/src/telegram/mod.rs
Normal file
0
holycow_backend/src/telegram/mod.rs
Normal file
|
@ -1,6 +0,0 @@
|
||||||
/** @type {import("next").NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
output: "export"
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
6
holycow_frontend/next.config.ts
Normal file
6
holycow_frontend/next.config.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import {NextConfig} from "next"
|
||||||
|
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {}
|
||||||
|
|
||||||
|
export default nextConfig
|
|
@ -3,11 +3,12 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack --port 30000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
|
"license": "EUPL-1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@awesome.me/kit-2b1ce28b9d": "^1.0.4",
|
"@awesome.me/kit-2b1ce28b9d": "^1.0.4",
|
||||||
"@steffo/bluelib": "^9.1.0",
|
"@steffo/bluelib": "^9.1.0",
|
||||||
|
|
14
holycow_frontend/src/app/[telegramId]/profile/page.tsx
Normal file
14
holycow_frontend/src/app/[telegramId]/profile/page.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import {ProfileBox} from "@/components/ProfileBox"
|
||||||
|
import {PlayerOO} from "@/holycow"
|
||||||
|
|
||||||
|
|
||||||
|
export default async function Page({params: {telegramId}}) {
|
||||||
|
const playerResponse = await fetch(`${process.env.BASE_URL}/api/results/telegram/${telegramId}`)
|
||||||
|
const player: PlayerOO = await playerResponse.json()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileBox
|
||||||
|
player={player}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
16
holycow_frontend/src/app/[telegramId]/report/page.tsx
Normal file
16
holycow_frontend/src/app/[telegramId]/report/page.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import {ReportBoxInteractive} from "@/components/ReportBoxInteractive"
|
||||||
|
import {PlayerO} from "@/holycow"
|
||||||
|
|
||||||
|
|
||||||
|
export default async function Page({params: {telegramId}}) {
|
||||||
|
const playersResponse = await fetch(`${process.env.BASE_URL}/api/results/`)
|
||||||
|
const players: PlayerO[] = await playersResponse.json()
|
||||||
|
const playerA: PlayerO = players.find(p => p.telegram_id == telegramId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportBoxInteractive
|
||||||
|
players={players}
|
||||||
|
playerA={playerA}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -6,7 +6,19 @@ nextjs-portal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator-lightest {
|
||||||
|
border-color: hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / .05);
|
||||||
|
}
|
||||||
|
|
||||||
:where(body) :where(.form-flex) > :where(.form-flex-choice, label) > :where(*) {
|
:where(body) :where(.form-flex) > :where(.form-flex-choice, label) > :where(*) {
|
||||||
/* TODO: Fix in bluelib */
|
/* TODO: Fix in bluelib */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fof {
|
||||||
|
text-align: center;
|
||||||
|
}
|
|
@ -1,34 +1,40 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { TelegramContext, useTelegramLoader } from "@/components/useTelegram";
|
import {TelegramContext, useTelegramLoader} from "@/components/useTelegram"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
|
||||||
import "@steffo/bluelib/dist/base.root.css";
|
import "@steffo/bluelib/dist/base.root.css"
|
||||||
import "@steffo/bluelib/dist/classic.root.css";
|
import "@steffo/bluelib/dist/classic.root.css"
|
||||||
import "@steffo/bluelib/dist/glass.root.css";
|
import "@steffo/bluelib/dist/glass.root.css"
|
||||||
import "@steffo/bluelib/dist/layouts-center.root.css";
|
import "@steffo/bluelib/dist/layouts-center.root.css"
|
||||||
import "@steffo/bluelib/dist/layouts-flex.root.css";
|
import "@steffo/bluelib/dist/layouts-flex.root.css"
|
||||||
import "@steffo/bluelib/dist/colors-purplestar.root.css";
|
import "@steffo/bluelib/dist/colors-purplestar.root.css"
|
||||||
import "@steffo/bluelib/dist/fonts-fira-ghpages.root.css";
|
import "@steffo/bluelib/dist/fonts-fira-ghpages.root.css"
|
||||||
import "./layout.css";
|
import "./layout.css"
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({children}) {
|
||||||
const {telegram, onReady} = useTelegramLoader()
|
const {telegram, onReady} = useTelegramLoader()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="it">
|
<html lang="it">
|
||||||
<head>
|
<head>
|
||||||
<Script
|
<Script
|
||||||
src={"https://telegram.org/js/telegram-web-app.js?56"}
|
src={"https://telegram.org/js/telegram-web-app.js?56"}
|
||||||
onLoad={onReady}
|
onLoad={onReady}
|
||||||
onReady={onReady}
|
onReady={onReady}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h2>
|
<h2>
|
||||||
Stagione 1
|
<span>
|
||||||
|
Stagione 1:
|
||||||
|
</span>
|
||||||
|
<br/>
|
||||||
|
<small>
|
||||||
|
Standard Brawl
|
||||||
|
</small>
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<TelegramContext.Provider value={telegram}>
|
<TelegramContext.Provider value={telegram}>
|
||||||
|
@ -38,12 +44,12 @@ export default function RootLayout({ children }) {
|
||||||
<p>
|
<p>
|
||||||
© Stefano Pigozzi
|
© Stefano Pigozzi
|
||||||
-
|
-
|
||||||
A quanto pare non posso mettere link esterni qui
|
Star Shard
|
||||||
-
|
-
|
||||||
Garasauto
|
che cursata le miniapp di telegram
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
15
holycow_frontend/src/app/none/page.tsx
Normal file
15
holycow_frontend/src/app/none/page.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<div className={"panel box fof"}>
|
||||||
|
<h3>
|
||||||
|
👍
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
hai scoperto la pagina di tutti i tempi
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
congrats!!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,89 +1,37 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
|
import {LoadingBox} from "@/components/LoadingBox"
|
||||||
|
import {useTelegram} from "@/components/useTelegram"
|
||||||
|
import {useRouter} from "next/navigation"
|
||||||
|
import {useEffect} from "react"
|
||||||
|
|
||||||
import { StatPanel } from "@/components/StatPanel";
|
|
||||||
import { useTelegram } from "@/components/useTelegram";
|
|
||||||
import {useEffect, useMemo} from "react"
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const router = useRouter()
|
||||||
const telegram = useTelegram()
|
const telegram = useTelegram()
|
||||||
const telegramData = telegram?.WebApp?.initDataUnsafe
|
const data = telegram?.WebApp?.initDataUnsafe
|
||||||
const userId = telegramData?.user?.id
|
const startParam = data?.start_param
|
||||||
const userName = telegramData?.user?.first_name ?? "???"
|
|
||||||
|
|
||||||
const resultsData = undefined
|
useEffect(
|
||||||
const resultsError = undefined
|
() => {
|
||||||
|
switch(startParam) {
|
||||||
|
case "profile":
|
||||||
|
router.replace(`/${data.user.id}/profile`)
|
||||||
|
return
|
||||||
|
case "report":
|
||||||
|
router.replace(`/${data.user.id}/report`)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
router.replace(`/none`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[startParam],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
if(telegramData.start_param === "report") {
|
<LoadingBox>
|
||||||
// TODO
|
Connessione a Telegram in corso...
|
||||||
}
|
</LoadingBox>
|
||||||
}, [telegramData])
|
)
|
||||||
|
|
||||||
const contents = useMemo(() => {
|
|
||||||
if(resultsError) {
|
|
||||||
return resultsError.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const played = resultsData?.["played"] ?? 0
|
|
||||||
const wins = resultsData?.["wins"] ?? 0
|
|
||||||
const rating = resultsData?.["rating"] ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={"chapter-1"}>
|
|
||||||
<div className={"panel box"}>
|
|
||||||
<h3>
|
|
||||||
{userName}
|
|
||||||
</h3>
|
|
||||||
<div className={"chapter-3"}>
|
|
||||||
<StatPanel
|
|
||||||
name={"Giocate"}
|
|
||||||
value={(
|
|
||||||
<data
|
|
||||||
className={classNames({
|
|
||||||
"fade": played === 0
|
|
||||||
})}
|
|
||||||
value={played}
|
|
||||||
>
|
|
||||||
{played}
|
|
||||||
</data>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<StatPanel
|
|
||||||
name={"Vinte"}
|
|
||||||
value={(
|
|
||||||
<data
|
|
||||||
className={classNames({
|
|
||||||
"fade": wins === 0
|
|
||||||
})}
|
|
||||||
value={wins}
|
|
||||||
>
|
|
||||||
{wins}
|
|
||||||
</data>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<StatPanel
|
|
||||||
name={"Rating"}
|
|
||||||
value={(
|
|
||||||
<data
|
|
||||||
className={classNames({
|
|
||||||
"fade": rating === 0
|
|
||||||
})}
|
|
||||||
value={rating}
|
|
||||||
>
|
|
||||||
{rating === 0 ? "-" : rating}
|
|
||||||
</data>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}, [userName, resultsData, resultsError])
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<main>
|
|
||||||
{contents}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,107 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useTelegram } from "@/components/useTelegram";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import {FormEvent, useCallback, useMemo, useState} from "react"
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const telegram = useTelegram()
|
|
||||||
const telegramData = telegram?.WebApp?.initDataUnsafe
|
|
||||||
const userId = telegramData?.user?.id
|
|
||||||
const userName = telegramData?.user?.first_name ?? "???"
|
|
||||||
|
|
||||||
const [opponent, setOpponent] = useState("")
|
|
||||||
const [result, setResult] = useState(null)
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
|
||||||
(e: FormEvent) => {
|
|
||||||
telegram?.WebApp?.sendData?.(`${result} ${opponent}`)
|
|
||||||
},
|
|
||||||
[telegram, result, opponent]
|
|
||||||
)
|
|
||||||
|
|
||||||
const contents = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<div className={"chapter-1"}>
|
|
||||||
<form
|
|
||||||
className={classNames({
|
|
||||||
"panel": true,
|
|
||||||
"box": true,
|
|
||||||
"form-flex": true,
|
|
||||||
"red": result === "L",
|
|
||||||
"yellow": result === "T",
|
|
||||||
"green": result === "W",
|
|
||||||
})}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
<h3>
|
|
||||||
Registra risultato
|
|
||||||
</h3>
|
|
||||||
<label>
|
|
||||||
<span>Tu</span>
|
|
||||||
<div>
|
|
||||||
{userName}
|
|
||||||
</div>
|
|
||||||
<span></span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Avversario</span>
|
|
||||||
<select onChange={(e) => setOpponent(e.target.value)} value={opponent}>
|
|
||||||
<option value={""}></option>
|
|
||||||
<option value={"34053709"}>@AleCose</option>
|
|
||||||
<option value={"843330513"}>@Alleander</option>
|
|
||||||
<option value={"200821462"}>@catstolker</option>
|
|
||||||
<option value={"454281712"}>@CookieSin</option>
|
|
||||||
<option value={"524944901"}>@druidsfluid</option>
|
|
||||||
<option value={"48371848"}>@Francesco_Cuoghi</option>
|
|
||||||
<option value={"148374774"}>@GioOmbra</option>
|
|
||||||
<option value={"19611986"}>@GoodBalu</option>
|
|
||||||
<option value={"131057096"}>@Malbyx</option>
|
|
||||||
<option value={"488463576"}>@Mallllco</option>
|
|
||||||
<option value={"33523022"}>@MaxBubblegum</option>
|
|
||||||
<option value={"139079908"}>@SnowyCoder</option>
|
|
||||||
<option value={"165792255"}>@Spaggia</option>
|
|
||||||
<option value={"25167391"}>@Steffo</option>
|
|
||||||
<option value={"890339572"}>@xZefyr</option>
|
|
||||||
<option value={"19097832"}>@zezelda</option>
|
|
||||||
</select>
|
|
||||||
<span></span>
|
|
||||||
</label>
|
|
||||||
<div className={"form-flex-choice"}>
|
|
||||||
<span>Risultato</span>
|
|
||||||
<div>
|
|
||||||
<label>
|
|
||||||
<input type={"radio"} name={"result"} value={"W"} onChange={(e) => setResult(e.target.value)} checked={result === "W"}/> Vittoria
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type={"radio"} name={"result"} value={"T"} onChange={(e) => setResult(e.target.value)} checked={result === "T"}/> Pareggio
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type={"radio"} name={"result"} value={"L"} onChange={(e) => setResult(e.target.value)} checked={result === "L"}/> Sconfitta
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type={"submit"}
|
|
||||||
value={"Invia"}
|
|
||||||
className={classNames({
|
|
||||||
// TODO: "fade": result === null || opponent === ""
|
|
||||||
"fade": true,
|
|
||||||
})}
|
|
||||||
disabled={
|
|
||||||
// TODO: result === null || opponent === ""
|
|
||||||
true
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}, [onSubmit, userName, opponent, result])
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<main>
|
|
||||||
{contents}
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
}
|
|
13
holycow_frontend/src/components/LoadingBox.module.css
Normal file
13
holycow_frontend/src/components/LoadingBox.module.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
@keyframes loading {
|
||||||
|
0% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1.00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
animation: loading 0.5s infinite alternate ease-in-out;
|
||||||
|
}
|
22
holycow_frontend/src/components/LoadingBox.tsx
Normal file
22
holycow_frontend/src/components/LoadingBox.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import classNames from "classnames"
|
||||||
|
import {ReactNode} from "react"
|
||||||
|
import style from "./LoadingBox.module.css"
|
||||||
|
|
||||||
|
|
||||||
|
export type LoadingBoxProps = {
|
||||||
|
children?: ReactNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingBox({children = "Loading..."}: LoadingBoxProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
"panel": true,
|
||||||
|
"box": true,
|
||||||
|
[style.loading]: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
59
holycow_frontend/src/components/ProfileBox.tsx
Normal file
59
holycow_frontend/src/components/ProfileBox.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import {StatPanel} from "@/components/StatPanel"
|
||||||
|
import {PlayerOO} from "@/holycow"
|
||||||
|
import classNames from "classnames"
|
||||||
|
|
||||||
|
|
||||||
|
export type ProfileBoxProps = {
|
||||||
|
player: PlayerOO
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function ProfileBox({player}: ProfileBoxProps) {
|
||||||
|
return (
|
||||||
|
<div className={"chapter-1"}>
|
||||||
|
<div className={"panel box"}>
|
||||||
|
<h3>
|
||||||
|
{player.username}
|
||||||
|
</h3>
|
||||||
|
<div className={"chapter-4"}>
|
||||||
|
<StatPanel
|
||||||
|
name={"Giocate"}
|
||||||
|
className={classNames({
|
||||||
|
"fade": player.played === 0,
|
||||||
|
})}
|
||||||
|
value={(
|
||||||
|
<data value={player.played}>
|
||||||
|
{player.played}
|
||||||
|
</data>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<StatPanel
|
||||||
|
name={"Vinte"}
|
||||||
|
className={classNames({
|
||||||
|
"fade": player.wins === 0,
|
||||||
|
"green": player.wins !== 0,
|
||||||
|
})}
|
||||||
|
value={(
|
||||||
|
<data value={player.wins}>
|
||||||
|
{player.wins}
|
||||||
|
</data>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{player.human_score !== null &&
|
||||||
|
<StatPanel
|
||||||
|
name={"★"}
|
||||||
|
className={"yellow"}
|
||||||
|
value={(
|
||||||
|
<span>
|
||||||
|
<data value={player.human_score}>
|
||||||
|
{player.human_score}
|
||||||
|
</data>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
97
holycow_frontend/src/components/ReportBox.tsx
Normal file
97
holycow_frontend/src/components/ReportBox.tsx
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import {Outcome, PlayerO} from "@/holycow"
|
||||||
|
import classNames from "classnames"
|
||||||
|
import {FormEvent} from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export type ReportBoxProps = {
|
||||||
|
players: PlayerO[],
|
||||||
|
|
||||||
|
playerA: PlayerO,
|
||||||
|
playerB?: PlayerO,
|
||||||
|
setPlayerB: (player?: PlayerO) => void,
|
||||||
|
|
||||||
|
outcome: Outcome,
|
||||||
|
setOutcome: (outcome: Outcome) => void,
|
||||||
|
|
||||||
|
name: string,
|
||||||
|
setName: (name: string) => void,
|
||||||
|
|
||||||
|
onSubmit: (e: FormEvent) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportBox({players, playerA, playerB, setPlayerB, outcome, setOutcome, name, setName, onSubmit}: ReportBoxProps) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={classNames({
|
||||||
|
"panel": true,
|
||||||
|
"box": true,
|
||||||
|
"form-flex": true,
|
||||||
|
"green": outcome === Outcome.AWins,
|
||||||
|
"red": outcome === Outcome.BWins,
|
||||||
|
"yellow": outcome === Outcome.Tie,
|
||||||
|
})}
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(e)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>
|
||||||
|
Registra risultato
|
||||||
|
</h3>
|
||||||
|
<hr className="separator-lightest"/>
|
||||||
|
<label>
|
||||||
|
<span>Titolo</span>
|
||||||
|
<input type={"text"} onChange={e => setName(e.target.value)} value={name} placeholder={"Scontro al Garasauto"}/>
|
||||||
|
<small>opzion.</small>
|
||||||
|
</label>
|
||||||
|
<hr className="separator-lightest"/>
|
||||||
|
<label>
|
||||||
|
<span>Tu</span>
|
||||||
|
<select disabled={true}>
|
||||||
|
<option>{playerA.username}</option>
|
||||||
|
</select>
|
||||||
|
<span>{playerA.human_score && `${playerA.human_score} ★`}</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Avv.</span>
|
||||||
|
<select onChange={e => setPlayerB(players.find(p => p.id == Number.parseInt(e.target.value)))} value={playerB?.id}>
|
||||||
|
<option value={undefined}></option>
|
||||||
|
{players
|
||||||
|
.filter(player => player.id !== playerA.id)
|
||||||
|
.map(player => (
|
||||||
|
<option key={player.id} value={player.id}>{player.username}</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<span>{playerB && playerB.human_score && `${playerB.human_score} ★`}</span>
|
||||||
|
</label>
|
||||||
|
<hr className="separator-lightest"/>
|
||||||
|
<div className={"form-flex-choice"}>
|
||||||
|
<span>Ris.</span>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input type={"radio"} name={"result"} value={"AWins"} checked={outcome === Outcome.AWins} onChange={e => e.target.checked && setOutcome(Outcome.AWins)}/> Vittoria
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type={"radio"} name={"result"} value={"Tie"} checked={outcome === Outcome.Tie} onChange={e => e.target.checked && setOutcome(Outcome.Tie)}/> Pareggio
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type={"radio"} name={"result"} value={"BWins"} checked={outcome === Outcome.BWins} onChange={e => e.target.checked && setOutcome(Outcome.BWins)}/> Sconfitta
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<hr className="separator-lightest"/>
|
||||||
|
<input
|
||||||
|
type={"submit"}
|
||||||
|
value={"Invia"}
|
||||||
|
className={classNames({
|
||||||
|
"fade": outcome === undefined || playerB === undefined,
|
||||||
|
})}
|
||||||
|
disabled={
|
||||||
|
outcome === undefined || playerB === undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
60
holycow_frontend/src/components/ReportBoxInteractive.tsx
Normal file
60
holycow_frontend/src/components/ReportBoxInteractive.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {ReportBox} from "@/components/ReportBox"
|
||||||
|
import {useTelegram} from "@/components/useTelegram"
|
||||||
|
import {Outcome, PlayerO} from "@/holycow"
|
||||||
|
import {useCallback, useState} from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export type ReportBoxInteractiveProps = {
|
||||||
|
players: PlayerO[],
|
||||||
|
playerA: PlayerO,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportBoxInteractive({players, playerA}: ReportBoxInteractiveProps) {
|
||||||
|
const [playerB, setPlayerB] = useState<PlayerO | undefined>(undefined)
|
||||||
|
const [outcome, setOutcome] = useState<Outcome | undefined>(undefined)
|
||||||
|
const [name, setName] = useState<string>("")
|
||||||
|
const [running, setRunning] = useState<boolean>(false)
|
||||||
|
const telegram = useTelegram()
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
() => {
|
||||||
|
if(!telegram) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRunning(true)
|
||||||
|
const body = JSON.stringify({
|
||||||
|
name: name === "" ? null : name,
|
||||||
|
player_a: playerA.id,
|
||||||
|
player_b: playerB.id,
|
||||||
|
outcome: outcome.toString(),
|
||||||
|
})
|
||||||
|
fetch("/api/matches/", {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}).finally(() => {
|
||||||
|
telegram?.WebApp?.close?.()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[telegram, name, playerA, playerB, outcome],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReportBox
|
||||||
|
players={players}
|
||||||
|
playerA={playerA}
|
||||||
|
playerB={playerB}
|
||||||
|
setPlayerB={setPlayerB}
|
||||||
|
outcome={outcome}
|
||||||
|
setOutcome={setOutcome}
|
||||||
|
name={name}
|
||||||
|
setName={setName}
|
||||||
|
onSubmit={running ? () => {
|
||||||
|
} : onSubmit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
import classNames from "classnames";
|
import classNames from "classnames"
|
||||||
import style from "./StatPanel.module.css"
|
import style from "./StatPanel.module.css"
|
||||||
|
|
||||||
export function StatPanel({name, value, display = value}) {
|
|
||||||
|
export function StatPanel({className, name, value, display = value}) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames("panel", style.panel)}>
|
<div className={classNames("panel", style.panel, className)}>
|
||||||
<h4 className={style.name}>
|
<h4 className={style.name}>
|
||||||
{name}
|
{name}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import {createContext, useCallback, useContext, useState} from "react"
|
||||||
|
|
||||||
|
|
||||||
export function useTelegramLoader() {
|
export function useTelegramLoader() {
|
||||||
|
@ -12,7 +12,7 @@ export function useTelegramLoader() {
|
||||||
return {telegram, onReady}
|
return {telegram, onReady}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TelegramContext = createContext(undefined);
|
export const TelegramContext = createContext(undefined)
|
||||||
|
|
||||||
export function useTelegram(): Telegram | undefined {
|
export function useTelegram(): Telegram | undefined {
|
||||||
return useContext(TelegramContext)
|
return useContext(TelegramContext)
|
||||||
|
|
22
holycow_frontend/src/holycow.ts
Normal file
22
holycow_frontend/src/holycow.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export enum Outcome {
|
||||||
|
AWins = "AWins",
|
||||||
|
BWins = "BWins",
|
||||||
|
Tie = "Tie",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type PlayerO = {
|
||||||
|
id: number,
|
||||||
|
telegram_id: number,
|
||||||
|
username: string,
|
||||||
|
human_score: null | number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerOO = {
|
||||||
|
id: number,
|
||||||
|
telegram_id: number,
|
||||||
|
username: string,
|
||||||
|
human_score: null | number,
|
||||||
|
played: number,
|
||||||
|
wins: number,
|
||||||
|
}
|
|
@ -2,17 +2,21 @@ interface Telegram {
|
||||||
WebApp?: TelegramWebApp,
|
WebApp?: TelegramWebApp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TelegramWebApp {
|
interface TelegramWebApp {
|
||||||
initDataUnsafe?: TelegramWebAppInitData
|
initDataUnsafe?: TelegramWebAppInitData
|
||||||
sendData?: (data: string) => void,
|
sendData?: (data: string) => void,
|
||||||
|
close?: () => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TelegramWebAppInitData {
|
interface TelegramWebAppInitData {
|
||||||
user: TelegramWebAppUser,
|
user: TelegramWebAppUser,
|
||||||
receiver: TelegramWebAppUser,
|
receiver: TelegramWebAppUser,
|
||||||
start_param?: string,
|
start_param?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface TelegramWebAppUser {
|
interface TelegramWebAppUser {
|
||||||
id: number,
|
id: number,
|
||||||
first_name: string,
|
first_name: string,
|
||||||
|
|
|
@ -1,41 +1,41 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue