mirror of https://git.sr.ht/~ashkeel/strimertul
First public commit \o/
This commit is contained in:
commit
cfb888dafb
|
@ -0,0 +1,5 @@
|
|||
*.exe
|
||||
*.db
|
||||
*.db.lock
|
||||
rice-box.go
|
||||
data/
|
|
@ -0,0 +1,661 @@
|
|||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
|
@ -0,0 +1,13 @@
|
|||
# Licensing
|
||||
|
||||
License names used in this document are as per [SPDX License List](https://spdx.org/licenses/).
|
||||
|
||||
The default license for this project is [AGPL-3.0-only](LICENSE).
|
||||
|
||||
## ISC
|
||||
|
||||
The following files are licensed under ISC:
|
||||
|
||||
```
|
||||
frontend/src/lib/strimertul-ws.ts
|
||||
```
|
|
@ -0,0 +1,33 @@
|
|||
# strimertul
|
||||
|
||||
Streaming helpers, includes:
|
||||
|
||||
- Extremely simple/fast disk-backed KV over websocket for interacting with web-based overlays
|
||||
- oh and it has pub/sub
|
||||
- Static file server for said overlays
|
||||
- Loyalty system that tracks viewers and allows them to redeem rewards and contribute to community goals
|
||||
- WIP betting system
|
||||
- Twitch IRC bot to tie everything together
|
||||
- WIP own backend integration (stulbe)
|
||||
|
||||
Platform support is limited to Twitch only for the time being (sorry!)
|
||||
|
||||
# Building
|
||||
|
||||
You need to build the frontend first!
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
```
|
||||
|
||||
Once that's done, just build the app like any other Go project
|
||||
|
||||
```sh
|
||||
go build
|
||||
```
|
||||
|
||||
# License
|
||||
|
||||
The entire project is licensed under [AGPL-3.0-only](LICENSE) (see `LICENSE`). For ISC exceptions, see [LICENSING.md](LICENSING.md).
|
|
@ -0,0 +1,46 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPAlreadyBet error = errors.New("you already have a bet")
|
||||
ErrPNoPrediction error = errors.New("there's nothing to bet for")
|
||||
ErrPBettingTimeOver error = errors.New("betting time is over")
|
||||
)
|
||||
|
||||
type PredictionBet struct {
|
||||
Amount uint64
|
||||
Team uint
|
||||
}
|
||||
|
||||
type Prediction struct {
|
||||
Active bool
|
||||
Deadline time.Time
|
||||
Bets map[string]PredictionBet
|
||||
Teams []string
|
||||
}
|
||||
|
||||
func NewPrediction(teams []string, bettingTime time.Duration) *Prediction {
|
||||
return &Prediction{
|
||||
Active: false,
|
||||
Deadline: time.Now().Add(bettingTime),
|
||||
Bets: make(map[string]PredictionBet),
|
||||
Teams: teams,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Prediction) AddBet(who string, teamId uint, amount uint64) error {
|
||||
_, ok := p.Bets[who]
|
||||
if ok {
|
||||
return ErrPAlreadyBet
|
||||
}
|
||||
|
||||
p.Bets[who] = PredictionBet{
|
||||
Amount: amount,
|
||||
Team: teamId,
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
quote_type = single
|
|
@ -0,0 +1,23 @@
|
|||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'import'],
|
||||
extends: ['airbnb-base', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
rules: {
|
||||
'no-console': 0,
|
||||
'import/extensions': 0,
|
||||
'no-use-before-define': 'off',
|
||||
'@typescript-eslint/no-use-before-define': ['error'],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
ignorePatterns: ['OLD/*'],
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
dist
|
||||
node_modules
|
||||
.parcel-cache
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"endOfLine": "auto",
|
||||
"trailingComma": "all"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "strimertul-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@reach/router": "^1.3.4",
|
||||
"@reduxjs/toolkit": "^1.5.1",
|
||||
"@types/node": "^15.0.0",
|
||||
"@types/reach__router": "^1.3.7",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"bulma": "^0.9.2",
|
||||
"parcel": "^2.0.0-beta.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"sass": "^1.32.11",
|
||||
"typescript": "^4.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "parcel src/*.html",
|
||||
"build": "rimraf ./dist && parcel build --dist-dir dist --public-url /ui src/*.html"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 1 Chrome version"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@parcel/transformer-sass": "^2.0.0-beta.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.6.1",
|
||||
"@typescript-eslint/parser": "^4.6.1",
|
||||
"eslint": "^7.12.1",
|
||||
"eslint-config-airbnb-base": "^14.2.0",
|
||||
"eslint-config-prettier": "^6.15.0",
|
||||
"eslint-import-resolver-typescript": "^2.3.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"prettier": "^2.1.2",
|
||||
"rimraf": "^3.0.2"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>strimertül control panel</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://jenil.github.io/bulmaswatch/darkly/bulmaswatch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main" class="is-fullheight"></div>
|
||||
<script src="index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createHistory, LocationProvider } from '@reach/router';
|
||||
|
||||
import store from './store';
|
||||
import App from './ui/App';
|
||||
|
||||
import './overrides.css';
|
||||
|
||||
// @ts-expect-error idk
|
||||
const history = createHistory(window);
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<LocationProvider history={history}>
|
||||
<App />
|
||||
</LocationProvider>
|
||||
</Provider>,
|
||||
document.getElementById('main'),
|
||||
);
|
|
@ -0,0 +1,44 @@
|
|||
import { ActionCreatorWithOptionalPayload, AsyncThunk } from '@reduxjs/toolkit';
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
import { APIState } from '../store/api/reducer';
|
||||
import { wsMessage } from './strimertul-ws';
|
||||
|
||||
export function useModule<T>({
|
||||
key,
|
||||
selector,
|
||||
getter,
|
||||
setter,
|
||||
asyncSetter,
|
||||
}: {
|
||||
key: string;
|
||||
selector: (state: APIState) => T;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
getter: AsyncThunk<T, void, {}>;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
setter: AsyncThunk<wsMessage, T, {}>;
|
||||
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
}): [T, AsyncThunk<wsMessage, T, {}>] {
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const data = useSelector((state: RootState) => selector(state.api));
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
dispatch(getter());
|
||||
}
|
||||
const subscriber = (newValue) => {
|
||||
dispatch(asyncSetter(JSON.parse(newValue) as T));
|
||||
};
|
||||
client.subscribe(key, subscriber);
|
||||
return () => {
|
||||
client.unsubscribe(key, subscriber);
|
||||
};
|
||||
}, []);
|
||||
return [data, setter];
|
||||
}
|
||||
|
||||
export default {
|
||||
useModule,
|
||||
};
|
|
@ -0,0 +1,208 @@
|
|||
export type SubscriptionHandler = (newValue: string) => void;
|
||||
|
||||
export type wsMessage = wsError | wsPush | wsResponse;
|
||||
|
||||
interface wsError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface wsPush {
|
||||
type: 'push';
|
||||
key: string;
|
||||
// eslint-disable-next-line camelcase
|
||||
new_value: string;
|
||||
}
|
||||
|
||||
interface wsResponse {
|
||||
ok: true;
|
||||
type: 'response';
|
||||
cmd: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export default class StrimertulWS {
|
||||
socket: WebSocket;
|
||||
|
||||
pending: Record<string, (...args) => void>;
|
||||
|
||||
onOpen: (() => void)[];
|
||||
|
||||
subscriptions: Record<string, SubscriptionHandler[]>;
|
||||
|
||||
constructor(address = 'ws://localhost:4337/ws') {
|
||||
this.pending = {};
|
||||
this.subscriptions = {};
|
||||
this.onOpen = [];
|
||||
this.socket = new WebSocket(address);
|
||||
this.socket.addEventListener('open', this.open.bind(this));
|
||||
this.socket.addEventListener('message', this.received.bind(this));
|
||||
}
|
||||
|
||||
async wait(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.onOpen.push(resolve);
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private open() {
|
||||
console.info('connected to server');
|
||||
this.onOpen.forEach((res) => res());
|
||||
this.onOpen = [];
|
||||
}
|
||||
|
||||
private received(event: MessageEvent) {
|
||||
const events = (event.data as string)
|
||||
.split('\n')
|
||||
.map((ev) => ev.trim())
|
||||
.filter((ev) => ev.length > 0);
|
||||
events.forEach((ev) => {
|
||||
const response: wsMessage = JSON.parse(ev ?? '""');
|
||||
if ('error' in response) {
|
||||
console.error('Received error from ws: ', response.error);
|
||||
// TODO show in UI somehow
|
||||
return;
|
||||
}
|
||||
switch (response.type) {
|
||||
case 'response':
|
||||
if (response.cmd in this.pending) {
|
||||
this.pending[response.cmd](response);
|
||||
delete this.pending[response.cmd];
|
||||
} else {
|
||||
console.warn(
|
||||
'Received a response for an unregistered request: ',
|
||||
response,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'push': {
|
||||
if (response.key in this.subscriptions) {
|
||||
this.subscriptions[response.key].forEach((fn) =>
|
||||
fn(response.new_value),
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'Received subscription push with no listeners: ',
|
||||
response,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Do nothing
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async send<R, T>(msg: T): Promise<R> {
|
||||
return new Promise((resolve) => {
|
||||
const payload = JSON.stringify(msg);
|
||||
this.socket.send(payload);
|
||||
this.pending[payload] = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async putKey(key: string, data: string): Promise<wsMessage> {
|
||||
return this.send({
|
||||
command: 'kset',
|
||||
data: {
|
||||
key,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async putJSON<T>(key: string, data: T): Promise<wsMessage> {
|
||||
return this.send({
|
||||
command: 'kset',
|
||||
data: {
|
||||
key,
|
||||
data: JSON.stringify(data),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getKey(key: string): Promise<string> {
|
||||
const response: wsError | wsResponse = await this.send({
|
||||
command: 'kget',
|
||||
data: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
if ('error' in response) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getJSON<T>(key: string): Promise<T> {
|
||||
const response: wsError | wsResponse = await this.send({
|
||||
command: 'kget',
|
||||
data: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
if ('error' in response) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return JSON.parse(response.data);
|
||||
}
|
||||
|
||||
async subscribe(key: string, fn: SubscriptionHandler): Promise<wsMessage> {
|
||||
if (key in this.subscriptions) {
|
||||
this.subscriptions[key].push(fn);
|
||||
} else {
|
||||
this.subscriptions[key] = [fn];
|
||||
}
|
||||
|
||||
return this.send({
|
||||
command: 'ksub',
|
||||
data: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(key: string, fn: SubscriptionHandler): Promise<boolean> {
|
||||
if (!(key in this.subscriptions)) {
|
||||
// No subscriptions, just warn and return
|
||||
console.warn(
|
||||
`Trying to unsubscribe from key "${key}" but no subscriptions could be found!`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get subscriber in list
|
||||
const index = this.subscriptions[key].findIndex((subfn) => subfn === fn);
|
||||
if (index < 0) {
|
||||
// No subscriptions, just warn and return
|
||||
console.warn(
|
||||
`Trying to unsubscribe from key "${key}" but specified function is not in the subscribers!`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove subscriber from list
|
||||
this.subscriptions[key].splice(index, 1);
|
||||
|
||||
// Check if array is empty
|
||||
if (this.subscriptions[key].length < 1) {
|
||||
// Send unsubscribe
|
||||
const res: wsResponse | wsError = await this.send({
|
||||
command: 'kunsub',
|
||||
data: {
|
||||
key,
|
||||
},
|
||||
});
|
||||
if ('error' in res) {
|
||||
console.warn(`unsubscribe failed: ${res.error}`);
|
||||
}
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
/* Dark theme fixes */
|
||||
body .button.is-static {
|
||||
background-color: #5d6b6b;
|
||||
}
|
||||
body .input, body .select select, body .textarea {
|
||||
background-color: #222727;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body .input::placeholder {
|
||||
color: #7e9292;
|
||||
}
|
||||
|
||||
body .input[disabled],body .select select[disabled], body .textarea[disabled], body .input[disabled]::placeholder {
|
||||
border-color: #5e6d6f;
|
||||
color: #464e4e;
|
||||
background-color: #6d7979;
|
||||
}
|
||||
|
||||
body .button.is-success {
|
||||
background-color: #388859;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
padding: 1rem;
|
||||
border: 2px solid #5e6d6f;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 .4em .4em;
|
||||
}
|
||||
|
||||
/* Custom padding for content */
|
||||
.content-pad {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Custom reward/goal classes */
|
||||
.reward-disabled, .goal-disabled {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.goal-reached {
|
||||
font-weight: bold;
|
||||
color:#1fdb5e;
|
||||
}
|
||||
.goal-point-percent {
|
||||
color:#879799;
|
||||
}
|
||||
|
||||
/* Nice expand/contract icon without FontAwesome! */
|
||||
.icon.expand-on, .icon.expand-off {
|
||||
transition: all 50ms;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon.expand-on {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.icon.expand-off {
|
||||
transform: rotate(-90deg) translateY(-2px);
|
||||
}
|
||||
|
||||
/* Side menu tweaks */
|
||||
aside.menu {
|
||||
padding: 1rem 0.25rem 0;
|
||||
background-color: #272e2e;
|
||||
position: fixed;
|
||||
top: 0; bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
div.app-content {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
p.menu-label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
aside.menu {
|
||||
position: inherit;
|
||||
overflow: inherit;
|
||||
flex: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
div.app-content {
|
||||
position: inherit;
|
||||
flex: 1;
|
||||
top: inherit; bottom: inherit; right: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullheight fixes */
|
||||
html, body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* fuck you minireset */
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
#main, section.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: 0 !important; padding: 0;
|
||||
}
|
||||
|
||||
/* Labels should be non-selectable */
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Swap order of "is-active" to work with <Link> */
|
||||
.tabs.is-boxed li a.is-active {
|
||||
background-color: #1f2424;
|
||||
}
|
||||
.tabs.is-boxed li a.is-active {
|
||||
border-color: #5e6d6f;
|
||||
border-bottom-color: rgb(94, 109, 111);
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
.tabs li a.is-active {
|
||||
border-bottom-color: #1abc9c;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-card .field {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.subroute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-active + .subroute {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
/* Sorting for tables */
|
||||
.sort-icon {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
span.sortable {
|
||||
color:#6bc8b4;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom sidebar */
|
||||
.sidebar .menu-list a.is-active {
|
||||
background-color: #5e6d6f;
|
||||
}
|
|
@ -0,0 +1,290 @@
|
|||
/* eslint-disable camelcase */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import {
|
||||
AsyncThunk,
|
||||
CaseReducer,
|
||||
createAction,
|
||||
createAsyncThunk,
|
||||
createSlice,
|
||||
PayloadAction,
|
||||
} from '@reduxjs/toolkit';
|
||||
import StrimertulWS from '../../lib/strimertul-ws';
|
||||
|
||||
const moduleConfigKey = 'stul-meta/modules';
|
||||
const httpConfigKey = 'http/config';
|
||||
const twitchBotConfigKey = 'twitchbot/config';
|
||||
const stulbeConfigKey = 'stulbe/config';
|
||||
const loyaltyConfigKey = 'loyalty/config';
|
||||
const loyaltyStorageKey = 'loyalty/users';
|
||||
const loyaltyRewardsKey = 'loyalty/rewards';
|
||||
const loyaltyGoalsKey = 'loyalty/goals';
|
||||
const loyaltyRedeemQueueKey = 'loyalty/redeem-queue';
|
||||
|
||||
interface ModuleConfig {
|
||||
configured: boolean;
|
||||
kv: boolean;
|
||||
static: boolean;
|
||||
twitchbot: boolean;
|
||||
stulbe: boolean;
|
||||
loyalty: boolean;
|
||||
}
|
||||
|
||||
interface HTTPConfig {
|
||||
bind: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TwitchBotConfig {
|
||||
username: string;
|
||||
oauth: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
interface StulbeConfig {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface LoyaltyConfig {
|
||||
currency: string;
|
||||
enable_live_check: boolean;
|
||||
points: {
|
||||
interval: number;
|
||||
amount: number;
|
||||
activity_bonus: number;
|
||||
};
|
||||
banlist: string[];
|
||||
}
|
||||
|
||||
export type LoyaltyStorage = Record<string, number>;
|
||||
|
||||
export interface LoyaltyReward {
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
price: number;
|
||||
required_info?: string;
|
||||
}
|
||||
|
||||
export interface LoyaltyGoal {
|
||||
enabled: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
total: number;
|
||||
contributed: number;
|
||||
contributors: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface LoyaltyRedeem {
|
||||
user: string;
|
||||
when: Date;
|
||||
reward: LoyaltyReward;
|
||||
}
|
||||
|
||||
export interface APIState {
|
||||
client: StrimertulWS;
|
||||
initialLoadComplete: boolean;
|
||||
loyalty: {
|
||||
users: LoyaltyStorage;
|
||||
rewards: LoyaltyReward[];
|
||||
goals: LoyaltyGoal[];
|
||||
redeemQueue: LoyaltyRedeem[];
|
||||
};
|
||||
moduleConfigs: {
|
||||
moduleConfig: ModuleConfig;
|
||||
httpConfig: HTTPConfig;
|
||||
twitchBotConfig: TwitchBotConfig;
|
||||
stulbeConfig: StulbeConfig;
|
||||
loyaltyConfig: LoyaltyConfig;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: APIState = {
|
||||
client: null,
|
||||
initialLoadComplete: false,
|
||||
loyalty: {
|
||||
users: null,
|
||||
rewards: null,
|
||||
goals: null,
|
||||
redeemQueue: null,
|
||||
},
|
||||
moduleConfigs: {
|
||||
moduleConfig: null,
|
||||
httpConfig: null,
|
||||
twitchBotConfig: null,
|
||||
stulbeConfig: null,
|
||||
loyaltyConfig: null,
|
||||
},
|
||||
};
|
||||
|
||||
function makeGetterThunk<T>(key: string) {
|
||||
return async (_: void, { getState }) => {
|
||||
const { api } = getState() as { api: APIState };
|
||||
return api.client.getJSON<T>(key);
|
||||
};
|
||||
}
|
||||
|
||||
function makeSetterThunk<T>(
|
||||
key: string,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
getter: AsyncThunk<T, void, {}>,
|
||||
) {
|
||||
return async (data: T, { getState, dispatch }) => {
|
||||
const { api } = getState() as { api: APIState };
|
||||
const result = await api.client.putJSON(key, data);
|
||||
if ('ok' in result) {
|
||||
if (result.ok) {
|
||||
// Re-load value from KV
|
||||
dispatch(getter());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function makeGetSetThunks<T>(key: string) {
|
||||
const getter = createAsyncThunk(`api/get/${key}`, makeGetterThunk<T>(key));
|
||||
const setter = createAsyncThunk(
|
||||
`api/set/${key}`,
|
||||
makeSetterThunk<T>(key, getter),
|
||||
);
|
||||
return { getter, setter };
|
||||
}
|
||||
|
||||
function makeModule<T>(
|
||||
key: string,
|
||||
selector: (state: APIState) => T,
|
||||
stateSetter: CaseReducer<APIState>,
|
||||
) {
|
||||
return {
|
||||
...makeGetSetThunks<T>(key),
|
||||
key,
|
||||
selector,
|
||||
stateSetter,
|
||||
asyncSetter: createAction<T>(`asyncSetter/${key}`),
|
||||
};
|
||||
}
|
||||
|
||||
export const createWSClient = createAsyncThunk(
|
||||
'api/createClient',
|
||||
async (address: string) => {
|
||||
const client = new StrimertulWS(address);
|
||||
await client.wait();
|
||||
return client;
|
||||
},
|
||||
);
|
||||
|
||||
export const modules = {
|
||||
moduleConfig: makeModule<ModuleConfig>(
|
||||
moduleConfigKey,
|
||||
(state) => state.moduleConfigs?.moduleConfig,
|
||||
(state, { payload }) => {
|
||||
state.moduleConfigs.moduleConfig = payload;
|
||||
},
|
||||
),
|
||||
httpConfig: makeModule<HTTPConfig>(
|
||||
httpConfigKey,
|
||||
(state) => state.moduleConfigs?.httpConfig,
|
||||
(state, { payload }) => {
|
||||
state.moduleConfigs.httpConfig = payload;
|
||||
},
|
||||
),
|
||||
twitchBotConfig: makeModule<TwitchBotConfig>(
|
||||
twitchBotConfigKey,
|
||||
(state) => state.moduleConfigs?.twitchBotConfig,
|
||||
(state, { payload }) => {
|
||||
state.moduleConfigs.twitchBotConfig = payload;
|
||||
},
|
||||
),
|
||||
stulbeConfig: makeModule<StulbeConfig>(
|
||||
stulbeConfigKey,
|
||||
(state) => state.moduleConfigs?.stulbeConfig,
|
||||
(state, { payload }) => {
|
||||
state.moduleConfigs.stulbeConfig = payload;
|
||||
},
|
||||
),
|
||||
loyaltyConfig: makeModule<LoyaltyConfig>(
|
||||
loyaltyConfigKey,
|
||||
(state) => state.moduleConfigs?.loyaltyConfig,
|
||||
(state, { payload }) => {
|
||||
state.moduleConfigs.loyaltyConfig = payload;
|
||||
},
|
||||
),
|
||||
loyaltyStorage: makeModule<LoyaltyStorage>(
|
||||
loyaltyStorageKey,
|
||||
(state) => state.loyalty.users,
|
||||
(state, { payload }) => {
|
||||
state.loyalty.users = payload;
|
||||
},
|
||||
),
|
||||
loyaltyRewards: makeModule<LoyaltyReward[]>(
|
||||
loyaltyRewardsKey,
|
||||
(state) => state.loyalty.rewards,
|
||||
(state, { payload }) => {
|
||||
state.loyalty.rewards = payload;
|
||||
},
|
||||
),
|
||||
loyaltyGoals: makeModule<LoyaltyGoal[]>(
|
||||
loyaltyGoalsKey,
|
||||
(state) => state.loyalty.goals,
|
||||
(state, { payload }) => {
|
||||
state.loyalty.goals = payload;
|
||||
},
|
||||
),
|
||||
loyaltyRedeemQueue: makeModule<LoyaltyRedeem[]>(
|
||||
loyaltyRedeemQueueKey,
|
||||
(state) => state.loyalty.redeemQueue,
|
||||
(state, { payload }) => {
|
||||
state.loyalty.redeemQueue = payload;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const apiReducer = createSlice({
|
||||
name: 'api',
|
||||
initialState,
|
||||
reducers: {
|
||||
initialLoadCompleted(state) {
|
||||
state.initialLoadComplete = true;
|
||||
},
|
||||
moduleConfigChanged(state, { payload }: PayloadAction<ModuleConfig>) {
|
||||
state.moduleConfigs.moduleConfig = payload;
|
||||
},
|
||||
httpConfigChanged(state, { payload }: PayloadAction<HTTPConfig>) {
|
||||
state.moduleConfigs.httpConfig = payload;
|
||||
},
|
||||
twitchBotConfigChanged(state, { payload }: PayloadAction<TwitchBotConfig>) {
|
||||
state.moduleConfigs.twitchBotConfig = payload;
|
||||
},
|
||||
stulbeConfigChanged(state, { payload }: PayloadAction<StulbeConfig>) {
|
||||
state.moduleConfigs.stulbeConfig = payload;
|
||||
},
|
||||
loyaltyConfigChanged(state, { payload }: PayloadAction<LoyaltyConfig>) {
|
||||
state.moduleConfigs.loyaltyConfig = payload;
|
||||
},
|
||||
loyaltyStorageChanged(state, { payload }: PayloadAction<LoyaltyStorage>) {
|
||||
state.loyalty.users = payload;
|
||||
},
|
||||
loyaltyRewardsChanged(state, { payload }: PayloadAction<LoyaltyReward[]>) {
|
||||
state.loyalty.rewards = payload;
|
||||
},
|
||||
loyaltyGoalsChanged(state, { payload }: PayloadAction<LoyaltyGoal[]>) {
|
||||
state.loyalty.goals = payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
|
||||
state.client = payload;
|
||||
});
|
||||
Object.values(modules).forEach((mod) => {
|
||||
builder.addCase(mod.getter.fulfilled, mod.stateSetter);
|
||||
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default apiReducer;
|
|
@ -0,0 +1,23 @@
|
|||
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import apiReducer from './api/reducer';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
api: apiReducer.reducer,
|
||||
},
|
||||
middleware: [
|
||||
...getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}),
|
||||
thunkMiddleware,
|
||||
],
|
||||
devTools: true,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useThunkDispatch = () => useDispatch<AppDispatch>();
|
||||
|
||||
export default store;
|
|
@ -0,0 +1,163 @@
|
|||
import { Link, Redirect, Router, useLocation } from '@reach/router';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
import { createWSClient } from '../store/api/reducer';
|
||||
import Home from './pages/Home';
|
||||
import HTTPPage from './pages/HTTP';
|
||||
import TwitchBotPage from './pages/twitchbot/Main';
|
||||
import TwitchBotSettingsPage from './pages/twitchbot/Settings';
|
||||
import StulbePage from './pages/Stulbe';
|
||||
import LoyaltyPage from './pages/loyalty/Main';
|
||||
import DebugPage from './pages/Debug';
|
||||
import LoyaltySettingPage from './pages/loyalty/Settings';
|
||||
import LoyaltyRewardsPage from './pages/loyalty/Rewards';
|
||||
import LoyaltyUserListPage from './pages/loyalty/UserList';
|
||||
import LoyaltyGoalsPage from './pages/loyalty/Goals';
|
||||
import TwitchBotCommandsPage from './pages/twitchbot/Commands';
|
||||
import TwitchBotModulesPage from './pages/twitchbot/Modules';
|
||||
import LoyaltyRedeemQueuePage from './pages/loyalty/Queue';
|
||||
|
||||
interface RouteItem {
|
||||
name: string;
|
||||
route: string;
|
||||
subroutes?: RouteItem[];
|
||||
}
|
||||
|
||||
const menu: RouteItem[] = [
|
||||
{
|
||||
name: 'Home',
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
name: 'Web server',
|
||||
route: '/http',
|
||||
},
|
||||
{
|
||||
name: 'Twitch Bot',
|
||||
route: '/twitchbot/',
|
||||
subroutes: [
|
||||
{
|
||||
name: 'Configuration',
|
||||
route: '/twitchbot/settings',
|
||||
},
|
||||
{
|
||||
name: 'Modules',
|
||||
route: '/twitchbot/modules',
|
||||
},
|
||||
{
|
||||
name: 'Custom commands',
|
||||
route: '/twitchbot/commands',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Loyalty points',
|
||||
route: '/loyalty/',
|
||||
subroutes: [
|
||||
{
|
||||
name: 'Configuration',
|
||||
route: '/loyalty/settings',
|
||||
},
|
||||
{
|
||||
name: 'Viewer points',
|
||||
route: '/loyalty/users',
|
||||
},
|
||||
{
|
||||
name: 'Redempions',
|
||||
route: '/loyalty/queue',
|
||||
},
|
||||
{
|
||||
name: 'Rewards',
|
||||
route: '/loyalty/rewards',
|
||||
},
|
||||
{
|
||||
name: 'Goals',
|
||||
route: '/loyalty/goals',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Stulbe integration',
|
||||
route: '/stulbe',
|
||||
},
|
||||
];
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
const loc = useLocation();
|
||||
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Create WS client
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
dispatch(
|
||||
createWSClient(
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'ws://localhost:4337/ws'
|
||||
: `ws://${loc.host}/ws`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!client) {
|
||||
return <div className="container">Loading...</div>;
|
||||
}
|
||||
|
||||
const basepath = process.env.NODE_ENV === 'development' ? '/' : '/ui/';
|
||||
|
||||
const routeItem = ({ route, name, subroutes }: RouteItem) => (
|
||||
<li key={route}>
|
||||
<Link
|
||||
getProps={({ isPartiallyCurrent, isCurrent }) => {
|
||||
const active = isCurrent || (subroutes && isPartiallyCurrent);
|
||||
return {
|
||||
className: active ? 'is-active' : '',
|
||||
};
|
||||
}}
|
||||
to={`${basepath}${route}`.replace(/\/\//gi, '/')}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
{subroutes ? (
|
||||
<ul className="subroute">{subroutes.map(routeItem)}</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="main-content columns is-fullheight">
|
||||
<aside className="menu sidebar column is-3 is-fullheight section">
|
||||
<p className="menu-label is-hidden-touch">Navigation</p>
|
||||
<ul className="menu-list">{menu.map(routeItem)}</ul>
|
||||
</aside>
|
||||
|
||||
<div className="app-content column is-9">
|
||||
<div className="content-pad">
|
||||
<Router basepath={basepath}>
|
||||
<Home path="/" />
|
||||
<HTTPPage path="http" />
|
||||
<TwitchBotPage path="twitchbot">
|
||||
<Redirect from="/" to="settings" noThrow />
|
||||
<TwitchBotSettingsPage path="settings" />
|
||||
<TwitchBotModulesPage path="modules" />
|
||||
<TwitchBotCommandsPage path="commands" />
|
||||
</TwitchBotPage>
|
||||
<LoyaltyPage path="loyalty">
|
||||
<Redirect from="/" to="settings" noThrow />
|
||||
<LoyaltySettingPage path="settings" />
|
||||
<LoyaltyUserListPage path="users" />
|
||||
<LoyaltyRedeemQueuePage path="queue" />
|
||||
<LoyaltyRewardsPage path="rewards" />
|
||||
<LoyaltyGoalsPage path="goals" />
|
||||
</LoyaltyPage>
|
||||
<StulbePage path="stulbe" />
|
||||
<DebugPage path="debug" />
|
||||
</Router>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
active: boolean;
|
||||
onClose?: () => void;
|
||||
onConfirm: () => void;
|
||||
confirmName?: string;
|
||||
confirmClass?: string;
|
||||
confirmEnabled?: boolean;
|
||||
cancelName?: string;
|
||||
cancelClass?: string;
|
||||
showCancel?: boolean;
|
||||
bgDismiss?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
active,
|
||||
title,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmName,
|
||||
confirmClass,
|
||||
confirmEnabled,
|
||||
cancelName,
|
||||
cancelClass,
|
||||
showCancel,
|
||||
bgDismiss,
|
||||
children,
|
||||
}: React.PropsWithChildren<ModalProps>): React.ReactElement {
|
||||
return (
|
||||
<div className={`modal ${active ? 'is-active' : ''}`}>
|
||||
<div
|
||||
className="modal-background"
|
||||
onClick={bgDismiss ? () => onClose() : null}
|
||||
></div>
|
||||
<div className="modal-card">
|
||||
<header className="modal-card-head">
|
||||
<p className="modal-card-title">{title}</p>
|
||||
{showCancel ? (
|
||||
<button
|
||||
className="delete"
|
||||
aria-label="close"
|
||||
onClick={() => onClose()}
|
||||
></button>
|
||||
) : null}
|
||||
</header>
|
||||
<section className="modal-card-body">{children}</section>
|
||||
<footer className="modal-card-foot">
|
||||
<button
|
||||
className={`button ${confirmClass ?? ''}`}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={() => onConfirm()}
|
||||
>
|
||||
{confirmName ?? 'OK'}
|
||||
</button>
|
||||
{showCancel ? (
|
||||
<button
|
||||
className={`button ${cancelClass ?? ''}`}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{cancelName ?? 'Cancel'}
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface PageListProps {
|
||||
current: number;
|
||||
max: number;
|
||||
min: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function PageList({
|
||||
current,
|
||||
max,
|
||||
min,
|
||||
onPageChange,
|
||||
}: PageListProps): React.ReactElement {
|
||||
return (
|
||||
<nav
|
||||
className="pagination is-centered is-small"
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
>
|
||||
<button
|
||||
className="button pagination-previous"
|
||||
disabled={current <= min}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
className="button pagination-next"
|
||||
disabled={current >= max}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
<ul className="pagination-list">
|
||||
{current > min ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={`Goto page ${min}`}
|
||||
onClick={() => onPageChange(min)}
|
||||
>
|
||||
{min}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{current > min + 2 ? (
|
||||
<li>
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{current > min + 1 ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={`Goto page ${current - 1}`}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
>
|
||||
{current - 1}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
<button
|
||||
className="pagination-link is-current"
|
||||
aria-label={`Page ${current}`}
|
||||
aria-current="page"
|
||||
>
|
||||
{current}
|
||||
</button>
|
||||
</li>
|
||||
{current < max ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={`Goto page ${current + 1}`}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
>
|
||||
{current + 1}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{current < max - 2 ? (
|
||||
<li>
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
) : null}
|
||||
{current < max - 1 ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={`Goto page ${max}`}
|
||||
onClick={() => onPageChange(max)}
|
||||
>
|
||||
{max}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Link } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export interface TabItem {
|
||||
route: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TabbedViewProps {
|
||||
tabs: TabItem[];
|
||||
}
|
||||
|
||||
export default function TabbedView({
|
||||
tabs,
|
||||
children,
|
||||
}: React.PropsWithChildren<TabbedViewProps>): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<div className="tabs is-boxed" style={{ marginBottom: 0 }}>
|
||||
<ul>
|
||||
{tabs.map(({ route, name }) => (
|
||||
<li key={route}>
|
||||
<Link
|
||||
getProps={({ isCurrent }) => {
|
||||
return {
|
||||
className: isCurrent ? 'is-active' : '',
|
||||
};
|
||||
}}
|
||||
to={route}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="tabContent">{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
|
||||
export default function DebugPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const api = useSelector((state: RootState) => state.api.client);
|
||||
const [readKey, setReadKey] = useState('');
|
||||
const [readValue, setReadValue] = useState('');
|
||||
const [writeKey, setWriteKey] = useState('');
|
||||
const [writeValue, setWriteValue] = useState('');
|
||||
const [writeErrorMsg, setWriteErrorMsg] = useState(null);
|
||||
|
||||
const performRead = async () => {
|
||||
const value = await api.getKey(readKey);
|
||||
setReadValue(value);
|
||||
};
|
||||
const performWrite = async () => {
|
||||
const result = await api.putKey(writeKey, writeValue);
|
||||
console.log(result);
|
||||
};
|
||||
const fixJSON = () => {
|
||||
try {
|
||||
setWriteValue(JSON.stringify(JSON.parse(writeValue)));
|
||||
setWriteErrorMsg(null);
|
||||
} catch (e) {
|
||||
setWriteErrorMsg(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="title is-3" style={{ color: '#fa3' }}>
|
||||
WELCOME TO HELL
|
||||
</p>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<label className="label">Read key</label>
|
||||
<div className="field has-addons">
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={readKey}
|
||||
onChange={(ev) => setReadKey(ev.target.value)}
|
||||
placeholder="some-bucket/some-key"
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={performRead}>
|
||||
Read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<textarea className="textarea" value={readValue} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column">
|
||||
<label className="label">Write key</label>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={writeKey}
|
||||
onChange={(ev) => setWriteKey(ev.target.value)}
|
||||
placeholder="some-bucket/some-key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={writeValue}
|
||||
onChange={(ev) => setWriteValue(ev.target.value)}
|
||||
/>
|
||||
{writeErrorMsg ? (
|
||||
<p>
|
||||
<code>{writeErrorMsg}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={performWrite}>
|
||||
Write
|
||||
</button>{' '}
|
||||
<button className="button" onClick={fixJSON}>
|
||||
Fix JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../store/api/reducer';
|
||||
|
||||
export default function HTTPPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [moduleConfig, setModuleConfig] = useModule(modules.moduleConfig);
|
||||
const [httpConfig, setHTTPConfig] = useModule(modules.httpConfig);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = moduleConfig === null || httpConfig === null;
|
||||
const active = moduleConfig?.static ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Web server configuration</h1>
|
||||
<div className="field">
|
||||
<label className="label">HTTP server port</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={busy}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="HTTP server bind"
|
||||
value={httpConfig?.bind ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
bind: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<p className="help">
|
||||
Note: You must restart strimertul after changing this!
|
||||
</p>
|
||||
</div>
|
||||
<label className="label">Static content</label>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy}
|
||||
checked={active}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.moduleConfigChanged({
|
||||
...moduleConfig,
|
||||
static: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Enable static server
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Static content root path</label>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="HTTP server bind"
|
||||
disabled={busy || !active}
|
||||
value={httpConfig?.path ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
path: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setModuleConfig(moduleConfig));
|
||||
dispatch(setHTTPConfig(httpConfig));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function Home(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
return <div>Work in progress!!</div>;
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../store/api/reducer';
|
||||
|
||||
export default function StulbePage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [moduleConfig, setModuleConfig] = useModule(modules.moduleConfig);
|
||||
const [stulbeConfig, setStulbeConfig] = useModule(modules.stulbeConfig);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = moduleConfig === null;
|
||||
const active = moduleConfig?.stulbe ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Stulbe integration settings</h1>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
disabled={busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.moduleConfigChanged({
|
||||
...moduleConfig,
|
||||
stulbe: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Enable Stulbe integration
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Stulbe Endpoint</label>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="https://stulbe.ovo.ovh"
|
||||
disabled={busy || !active}
|
||||
value={stulbeConfig?.endpoint ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.stulbeConfigChanged({
|
||||
...stulbeConfig,
|
||||
endpoint: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setModuleConfig(moduleConfig));
|
||||
dispatch(setStulbeConfig(stulbeConfig));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,395 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { RootState } from '../../../store';
|
||||
import { LoyaltyGoal, modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
|
||||
interface GoalItemProps {
|
||||
item: LoyaltyGoal;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
function GoalItem({ item, onToggleState, onEdit, onDelete }: GoalItemProps) {
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ?? 'points',
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const placeholder = 'https://bulma.io/images/placeholders/128x128.png';
|
||||
const contributors = Object.entries(item.contributors ?? {}).sort(
|
||||
([, pointsA], [, pointsB]) => pointsB - pointsA,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
<div className="media-left">
|
||||
<figure className="image is-32x32">
|
||||
<img src={item.image || placeholder} alt="Icon" />
|
||||
</figure>
|
||||
</div>
|
||||
{item.enabled ? (
|
||||
item.name
|
||||
) : (
|
||||
<span className="goal-disabled">{item.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.contributed >= item.total ? (
|
||||
<span className="goal-reached">Reached!</span>
|
||||
) : (
|
||||
<>
|
||||
{item.contributed} / {item.total} {currency}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{item.description}
|
||||
<div className="contributors" style={{ marginTop: '1rem' }}>
|
||||
{contributors.length > 0 ? (
|
||||
<>
|
||||
<b>Contributors:</b>
|
||||
<table className="table is-striped is-narrow">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Points</th>
|
||||
</tr>
|
||||
{contributors.map(([user, points]) => (
|
||||
<tr>
|
||||
<td>{user}</td>
|
||||
<td>
|
||||
{points}{' '}
|
||||
<span className="goal-point-percent">
|
||||
({Math.round((points / item.total) * 10000) / 100}%)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<b>No one has contributed yet :(</b>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? 'Disable' : 'Enable'}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
Edit
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GoalModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (r: LoyaltyGoal) => void;
|
||||
onClose: () => void;
|
||||
initialData?: LoyaltyGoal;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function GoalModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: GoalModalProps) {
|
||||
const [loyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
const [goals] = useModule(modules.loyaltyGoals);
|
||||
|
||||
const [id, setID] = useState(initialData?.id ?? '');
|
||||
const [name, setName] = useState(initialData?.name ?? '');
|
||||
const [image, setImage] = useState(initialData?.image ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
initialData?.description ?? '',
|
||||
);
|
||||
const [total, setTotal] = useState(initialData?.total ?? 0);
|
||||
|
||||
const setIDex = (newID) =>
|
||||
setID(newID.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-'));
|
||||
|
||||
const slug = id || name?.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-') || '';
|
||||
const idExists = goals?.some((reward) => reward.id === slug) ?? false;
|
||||
const idInvalid = slug !== initialData?.id && idExists;
|
||||
|
||||
const validForm = idInvalid === false && name !== '' && total >= 0;
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm({
|
||||
id: slug,
|
||||
name,
|
||||
description,
|
||||
total,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
image,
|
||||
contributed: initialData?.contributed ?? 0,
|
||||
contributors: initialData?.contributors ?? {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Reward ID</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={idInvalid ? 'input is-danger' : 'input'}
|
||||
type="text"
|
||||
placeholder="reward_id_here"
|
||||
value={slug}
|
||||
onChange={(ev) => setIDex(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
{idInvalid ? (
|
||||
<p className="help is-danger">
|
||||
There is already a reward with this ID! Please choose a
|
||||
different one.
|
||||
</p>
|
||||
) : (
|
||||
<p className="help">
|
||||
Choose a simple name that can be referenced by other software.
|
||||
It will be auto-generated from the reward name if you leave it
|
||||
blank.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Name</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="My dream goal"
|
||||
value={name ?? ''}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Icon</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Image URL"
|
||||
value={image ?? ''}
|
||||
onChange={(ev) => setImage(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Description</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="What's gonna happen when we reach this goal?"
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Required</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={total ?? ''}
|
||||
onChange={(ev) => setTotal(parseInt(ev.target.value, 10))}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{loyaltyConfig?.currency}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoyaltyGoalsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [goals, setGoals] = useModule(modules.loyaltyGoals);
|
||||
const [moduleConfig] = useModule(modules.moduleConfig);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const twitchBotActive = moduleConfig?.twitchbot ?? false;
|
||||
const loyaltyEnabled = moduleConfig?.loyalty ?? false;
|
||||
const active = twitchBotActive && loyaltyEnabled;
|
||||
|
||||
const [goalFilter, setGoalFilter] = useState('');
|
||||
const goalFilterLC = goalFilter.toLowerCase();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyGoal, setShowModifyGoal] = useState(null);
|
||||
|
||||
const createGoal = (newGoal: LoyaltyGoal) => {
|
||||
dispatch(setGoals([...(goals ?? []), newGoal]));
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const toggleGoal = (goalID: string) => {
|
||||
dispatch(
|
||||
setGoals(
|
||||
goals.map((entry) =>
|
||||
entry.id === goalID
|
||||
? {
|
||||
...entry,
|
||||
enabled: !entry.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const modifyGoal = (originalGoalID: string, goal: LoyaltyGoal) => {
|
||||
dispatch(
|
||||
setGoals(
|
||||
goals.map((entry) => (entry.id === originalGoalID ? goal : entry)),
|
||||
),
|
||||
);
|
||||
setShowModifyGoal(null);
|
||||
};
|
||||
|
||||
const deleteGoal = (goalID: string) => {
|
||||
dispatch(setGoals(goals.filter((entry) => entry.id !== goalID)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Community goals</h1>
|
||||
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button"
|
||||
disabled={!active}
|
||||
onClick={() => setCreateModal(true)}
|
||||
>
|
||||
New goal
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Search by name"
|
||||
value={goalFilter}
|
||||
onChange={(ev) => setGoalFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GoalModal
|
||||
title="New goal"
|
||||
confirmText="Create"
|
||||
active={createModal}
|
||||
onConfirm={createGoal}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyGoal ? (
|
||||
<GoalModal
|
||||
title="Modify goal"
|
||||
confirmText="Edit"
|
||||
active={true}
|
||||
onConfirm={(goal) => modifyGoal(showModifyGoal.id, goal)}
|
||||
initialData={showModifyGoal}
|
||||
onClose={() => setShowModifyGoal(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="goal-list" style={{ marginTop: '1rem' }}>
|
||||
{goals
|
||||
?.filter((goal) => goal.name.toLowerCase().includes(goalFilterLC))
|
||||
.map((goal) => (
|
||||
<GoalItem
|
||||
key={goal.name}
|
||||
item={goal}
|
||||
onDelete={() => deleteGoal(goal.id)}
|
||||
onEdit={() => setShowModifyGoal(goal)}
|
||||
onToggleState={() => toggleGoal(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function LoyaltyPage({
|
||||
children,
|
||||
}: RouteComponentProps<React.PropsWithChildren<unknown>>): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import PageList from '../../components/PageList';
|
||||
|
||||
interface SortingOrder {
|
||||
key: 'user' | 'when';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function LoyaltyRedeemQueuePage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [redemptions, setRedeemQueue] = useModule(modules.loyaltyRedeemQueue);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingOrder>({
|
||||
key: 'when',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
|
||||
const changeSort = (key: 'user' | 'when') => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
const filtered =
|
||||
redemptions?.filter(({ user }) => user.includes(usernameFilter)) ?? [];
|
||||
|
||||
const sortedEntries = filtered;
|
||||
switch (sorting.key) {
|
||||
case 'user':
|
||||
if (sorting.order === 'asc') {
|
||||
sortedEntries.sort((a, b) => (a.user > b.user ? 1 : -1));
|
||||
} else {
|
||||
sortedEntries.sort((a, b) => (a.user < b.user ? 1 : -1));
|
||||
}
|
||||
break;
|
||||
case 'when':
|
||||
if (sorting.order === 'asc') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.when.toString()) - Date.parse(b.when.toString()),
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.when.toString()) - Date.parse(a.when.toString()),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// unreacheable
|
||||
}
|
||||
|
||||
const paged = sortedEntries.slice(
|
||||
page * entriesPerPage,
|
||||
(page + 1) * entriesPerPage,
|
||||
);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Redemption queue</h1>
|
||||
{redemptions ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input is-small"
|
||||
type="text"
|
||||
placeholder="Search by username"
|
||||
value={usernameFilter}
|
||||
onChange={(ev) =>
|
||||
setUsernameFilter(ev.target.value.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
<table className="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '20%' }}>
|
||||
<span className="sortable" onClick={() => changeSort('when')}>
|
||||
Date
|
||||
{sorting.key === 'when' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sortable" onClick={() => changeSort('user')}>
|
||||
Username
|
||||
{sorting.key === 'user' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th>Reward name</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{paged.map((redemption) => (
|
||||
<tr
|
||||
key={`${redemption.when}-${redemption.user}-${redemption.reward.id}`}
|
||||
>
|
||||
<td>{new Date(redemption.when).toLocaleString()}</td>
|
||||
<td>{redemption.user}</td>
|
||||
<td>{redemption.reward.name}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Redemption queue is not available (loyalty disabled or no one has
|
||||
redeemed anything yet)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,421 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { RootState } from '../../../store';
|
||||
import { LoyaltyReward, modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
|
||||
interface RewardItemProps {
|
||||
item: LoyaltyReward;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
function RewardItem({
|
||||
item,
|
||||
onToggleState,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: RewardItemProps) {
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ?? 'points',
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const placeholder = 'https://bulma.io/images/placeholders/128x128.png';
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
<div className="media-left">
|
||||
<figure className="image is-32x32">
|
||||
<img src={item.image || placeholder} alt="Icon" />
|
||||
</figure>
|
||||
</div>
|
||||
{item.enabled ? (
|
||||
item.name
|
||||
) : (
|
||||
<span className="reward-disabled">{item.name}</span>
|
||||
)}
|
||||
<code style={{ backgroundColor: 'transparent', color: 'inherit' }}>
|
||||
(<span style={{ color: '#1abc9c' }}>{item.id}</span>)
|
||||
</code>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.price} {currency}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{item.description}
|
||||
{item.required_info ? (
|
||||
<>
|
||||
<b>Required info:</b> {item.required_info}
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? 'Disable' : 'Enable'}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
Edit
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RewardModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (r: LoyaltyReward) => void;
|
||||
onClose: () => void;
|
||||
initialData?: LoyaltyReward;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function RewardModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: RewardModalProps) {
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ?? 'points',
|
||||
);
|
||||
const [rewards] = useModule(modules.loyaltyRewards);
|
||||
|
||||
const [id, setID] = useState(initialData?.id ?? '');
|
||||
const [name, setName] = useState(initialData?.name ?? '');
|
||||
const [image, setImage] = useState(initialData?.image ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
initialData?.description ?? '',
|
||||
);
|
||||
const [price, setPrice] = useState(initialData?.price ?? 0);
|
||||
const [extraRequired, setExtraRequired] = useState(
|
||||
initialData?.required_info !== null,
|
||||
);
|
||||
const [extraDetails, setExtraDetails] = useState(
|
||||
initialData?.required_info ?? '',
|
||||
);
|
||||
|
||||
const setIDex = (newID) =>
|
||||
setID(newID.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-'));
|
||||
|
||||
const slug = id || name?.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-') || '';
|
||||
const idExists = rewards?.some((reward) => reward.id === slug) ?? false;
|
||||
const idInvalid = slug !== initialData?.id && idExists;
|
||||
|
||||
const validForm = idInvalid === false && name !== '' && price >= 0;
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm({
|
||||
id: slug,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
image,
|
||||
required_info: extraRequired ? extraDetails : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Reward ID</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={idInvalid ? 'input is-danger' : 'input'}
|
||||
type="text"
|
||||
placeholder="reward_id_here"
|
||||
value={slug}
|
||||
onChange={(ev) => setIDex(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
{idInvalid ? (
|
||||
<p className="help is-danger">
|
||||
There is already a reward with this ID! Please choose a
|
||||
different one.
|
||||
</p>
|
||||
) : (
|
||||
<p className="help">
|
||||
Choose a simple name that can be referenced by other software.
|
||||
It will be auto-generated from the reward name if you leave it
|
||||
blank.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Name</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="My awesome reward"
|
||||
value={name ?? ''}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Icon</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Image URL"
|
||||
value={image ?? ''}
|
||||
onChange={(ev) => setImage(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Description</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="What's so cool about this reward?"
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
></textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Cost</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={price ?? ''}
|
||||
onChange={(ev) => setPrice(parseInt(ev.target.value, 10))}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{currency}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal"></div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={extraRequired}
|
||||
onChange={(ev) => setExtraRequired(ev.target.checked)}
|
||||
/>{' '}
|
||||
Requires viewer-specified details
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{extraRequired ? (
|
||||
<>
|
||||
<div className="field is-horizontal">
|
||||
<div className="field-label is-normal">
|
||||
<label className="label">Required info</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="What extra detail to ask the viewer for"
|
||||
value={extraDetails ?? ''}
|
||||
onChange={(ev) => setExtraDetails(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoyaltyRewardsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [rewards, setRewards] = useModule(modules.loyaltyRewards);
|
||||
const [moduleConfig] = useModule(modules.moduleConfig);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const twitchBotActive = moduleConfig?.twitchbot ?? false;
|
||||
const loyaltyEnabled = moduleConfig?.loyalty ?? false;
|
||||
const active = twitchBotActive && loyaltyEnabled;
|
||||
|
||||
const [rewardFilter, setRewardFilter] = useState('');
|
||||
const rewardFilterLC = rewardFilter.toLowerCase();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyReward, setShowModifyReward] = useState(null);
|
||||
|
||||
const createReward = (newReward: LoyaltyReward) => {
|
||||
dispatch(setRewards([...(rewards ?? []), newReward]));
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const toggleReward = (rewardID: string) => {
|
||||
dispatch(
|
||||
setRewards(
|
||||
rewards.map((entry) =>
|
||||
entry.id === rewardID
|
||||
? {
|
||||
...entry,
|
||||
enabled: !entry.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const modifyReward = (originRewardID: string, reward: LoyaltyReward) => {
|
||||
dispatch(
|
||||
setRewards(
|
||||
rewards.map((entry) => (entry.id === originRewardID ? reward : entry)),
|
||||
),
|
||||
);
|
||||
setShowModifyReward(null);
|
||||
};
|
||||
|
||||
const deleteReward = (rewardID: string) => {
|
||||
dispatch(setRewards(rewards.filter((entry) => entry.id !== rewardID)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Loyalty rewards</h1>
|
||||
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button"
|
||||
disabled={!active}
|
||||
onClick={() => setCreateModal(true)}
|
||||
>
|
||||
New reward
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Search by name"
|
||||
value={rewardFilter}
|
||||
onChange={(ev) => setRewardFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RewardModal
|
||||
title="New reward"
|
||||
confirmText="Create"
|
||||
active={createModal}
|
||||
onConfirm={createReward}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyReward ? (
|
||||
<RewardModal
|
||||
title="Modify reward"
|
||||
confirmText="Edit"
|
||||
active={true}
|
||||
onConfirm={(reward) => modifyReward(showModifyReward.id, reward)}
|
||||
initialData={showModifyReward}
|
||||
onClose={() => setShowModifyReward(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="reward-list" style={{ marginTop: '1rem' }}>
|
||||
{rewards
|
||||
?.filter((reward) =>
|
||||
reward.name.toLowerCase().includes(rewardFilterLC),
|
||||
)
|
||||
.map((reward) => (
|
||||
<RewardItem
|
||||
key={reward.id}
|
||||
item={reward}
|
||||
onDelete={() => deleteReward(reward.id)}
|
||||
onEdit={() => setShowModifyReward(reward)}
|
||||
onToggleState={() => toggleReward(reward.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
|
||||
function getInterval(duration: number): [number, number] {
|
||||
if (duration % 3600 === 0) {
|
||||
return [duration / 3600, 3600];
|
||||
}
|
||||
if (duration % 60 === 0) {
|
||||
return [duration / 60, 60];
|
||||
}
|
||||
return [duration, 1];
|
||||
}
|
||||
|
||||
export default function LoyaltySettingPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [loyaltyConfig, setLoyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
const [moduleConfig, setModuleConfig] = useModule(modules.moduleConfig);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const twitchBotActive = moduleConfig?.twitchbot ?? false;
|
||||
const loyaltyEnabled = moduleConfig?.loyalty ?? false;
|
||||
const active = twitchBotActive && loyaltyEnabled;
|
||||
|
||||
const [tempIntervalNum, setTempIntervalNum] = useState(null);
|
||||
const [tempIntervalMult, setTempIntervalMult] = useState(null);
|
||||
|
||||
const [intervalNum, intervalMultiplier] = getInterval(
|
||||
loyaltyConfig?.points?.interval ?? 0,
|
||||
);
|
||||
|
||||
const stulbeEnabled = moduleConfig?.stulbe ?? false;
|
||||
const liveCheck = loyaltyConfig?.enable_live_check ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (loyaltyConfig?.points) {
|
||||
if (tempIntervalNum === null) {
|
||||
setTempIntervalNum(intervalNum);
|
||||
}
|
||||
if (tempIntervalMult === null) {
|
||||
setTempIntervalMult(intervalMultiplier);
|
||||
}
|
||||
}
|
||||
}, [loyaltyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
interval: tempIntervalNum * tempIntervalMult,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [tempIntervalNum, tempIntervalMult]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Loyalty system configuration</h1>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!twitchBotActive}
|
||||
checked={active}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
setModuleConfig({
|
||||
...moduleConfig,
|
||||
loyalty: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Enable loyalty points{' '}
|
||||
{twitchBotActive ? '' : '(TwitchBot must be enabled for this!)'}
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Currency name</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="points"
|
||||
value={loyaltyConfig?.currency ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
currency: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<label className="label">
|
||||
How often to give {loyaltyConfig?.currency || 'points'}
|
||||
</label>
|
||||
<div className="field has-addons" style={{ marginBottom: 0 }}>
|
||||
<p className="control">
|
||||
<a className="button is-static">Give</a>
|
||||
</p>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={loyaltyConfig?.points?.amount ?? ''}
|
||||
onChange={(ev) => {
|
||||
const amount = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(amount)) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
amount,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">every</a>
|
||||
</p>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={intervalNum ?? ''}
|
||||
onChange={(ev) => {
|
||||
const intNum = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
setTempIntervalNum(intNum);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<span className="select">
|
||||
<select
|
||||
value={intervalMultiplier.toString() ?? ''}
|
||||
disabled={!active}
|
||||
onChange={(ev) => {
|
||||
const intMult = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intMult)) {
|
||||
return;
|
||||
}
|
||||
setTempIntervalMult(intMult);
|
||||
}}
|
||||
>
|
||||
<option value="1">seconds</option>
|
||||
<option value="60">minutes</option>
|
||||
<option value="3600">hours</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Bonus points for active users</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={loyaltyConfig?.points?.activity_bonus ?? ''}
|
||||
onChange={(ev) => {
|
||||
const bonus = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(bonus)) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
activity_bonus: bonus,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!active || !stulbeEnabled}
|
||||
checked={liveCheck}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
setLoyaltyConfig({
|
||||
...loyaltyConfig,
|
||||
enable_live_check: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Enable check to only add points when live{' '}
|
||||
{stulbeEnabled ? (
|
||||
'(requires Stulbe)'
|
||||
) : (
|
||||
<span className="has-text-danger">
|
||||
(Stulbe must be enabled for this!)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setLoyaltyConfig(loyaltyConfig));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import PageList from '../../components/PageList';
|
||||
|
||||
interface SortingOrder {
|
||||
key: 'user' | 'points';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
export default function LoyaltyUserListPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [users] = useModule(modules.loyaltyStorage);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingOrder>({
|
||||
key: 'points',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
|
||||
const changeSort = (key: 'user' | 'points') => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
const rawEntries = Object.entries(users ?? []);
|
||||
const filtered = rawEntries.filter(([user]) => user.includes(usernameFilter));
|
||||
|
||||
const sortedEntries = filtered;
|
||||
switch (sorting.key) {
|
||||
case 'user':
|
||||
if (sorting.order === 'asc') {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA > userB ? 1 : -1));
|
||||
} else {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA < userB ? 1 : -1));
|
||||
}
|
||||
break;
|
||||
case 'points':
|
||||
if (sorting.order === 'asc') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, pointsA], [_b, pointsB]) => pointsA - pointsB);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, pointsA], [_b, pointsB]) => pointsB - pointsA);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// unreacheable
|
||||
}
|
||||
|
||||
const paged = sortedEntries.slice(
|
||||
page * entriesPerPage,
|
||||
(page + 1) * entriesPerPage,
|
||||
);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">All viewers with points</h1>
|
||||
{users ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input is-small"
|
||||
type="text"
|
||||
placeholder="Search by username"
|
||||
value={usernameFilter}
|
||||
onChange={(ev) =>
|
||||
setUsernameFilter(ev.target.value.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
<table className="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span className="sortable" onClick={() => changeSort('user')}>
|
||||
Username
|
||||
{sorting.key === 'user' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ width: '20%' }}>
|
||||
<span
|
||||
className="sortable"
|
||||
onClick={() => changeSort('points')}
|
||||
>
|
||||
Points
|
||||
{sorting.key === 'points' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{paged.map(([user, points]) => (
|
||||
<tr key={user}>
|
||||
<td>{user}</td>
|
||||
<td>{points}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Viewer list is not available (loyalty disabled or no one has points)
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function TwitchBotCommandsPage(
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
return <>WIP!!</>;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function TwitchBotPage({
|
||||
children,
|
||||
}: RouteComponentProps<React.PropsWithChildren<unknown>>): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function TwitchBotModulesPage(
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
return <>WIP!!</>;
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
|
||||
export default function TwitchBotSettingsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [moduleConfig, setModuleConfig] = useModule(modules.moduleConfig);
|
||||
const [twitchBotConfig, setTwitchBotConfig] = useModule(
|
||||
modules.twitchBotConfig,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = moduleConfig === null;
|
||||
const active = moduleConfig?.twitchbot ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">Twitch bot configuration</h1>
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
disabled={busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.moduleConfigChanged({
|
||||
...moduleConfig,
|
||||
twitchbot: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
Enable twitch bot
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Twitch channel</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Twitch channel name"
|
||||
value={twitchBotConfig?.channel ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
channel: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">
|
||||
Bot username (must be a valid Twitch account)
|
||||
</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Bot username"
|
||||
value={twitchBotConfig?.username ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
username: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Bot OAuth token</label>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="Bot OAuth token"
|
||||
value={twitchBotConfig?.oauth ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
oauth: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<p className="help">
|
||||
You can get this by logging in with the bot account and going here:{' '}
|
||||
<a href="https://twitchapps.com/tmi/">https://twitchapps.com/tmi/</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setModuleConfig(moduleConfig));
|
||||
dispatch(setTwitchBotConfig(twitchBotConfig));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"lib": ["es2017", "dom"]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
module github.com/strimertul/strimertul
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/dgraph-io/badger/v3 v3.2011.1
|
||||
github.com/gempir/go-twitch-irc/v2 v2.5.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/json-iterator/go v1.1.11
|
||||
github.com/nicklaw5/helix v1.13.1
|
||||
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4
|
||||
golang.org/x/net v0.0.0-20201024042810-be3efd7ff127 // indirect
|
||||
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c // indirect
|
||||
)
|
|
@ -0,0 +1,162 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cosiner/argv v0.1.0/go.mod h1:EusR6TucWKX+zFgtdUsKT2Cvg45K5rtpCcWz4hK06d8=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2011.1 h1:Hmyof0WMEF/QtutX5SQHzIMnJQxb/IrSzhjckV2SD6g=
|
||||
github.com/dgraph-io/badger/v3 v3.2011.1/go.mod h1:0rLLrQpKVQAL0or/lBLMQznhr6dWWX7h5AKnmnqx268=
|
||||
github.com/dgraph-io/ristretto v0.0.4-0.20210122082011-bb5d392ed82d h1:eQYOG6A4td1tht0NdJB9Ls6DsXRGb2Ft6X9REU/MbbE=
|
||||
github.com/dgraph-io/ristretto v0.0.4-0.20210122082011-bb5d392ed82d/go.mod h1:tv2ec8nA7vRpSYX7/MbP52ihrUMXIHit54CQMq8npXQ=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gempir/go-twitch-irc/v2 v2.5.0 h1:aybXNoyDNQaa4vHhXb0UpIDmspqutQUmXIYUFsjgecU=
|
||||
github.com/gempir/go-twitch-irc/v2 v2.5.0/go.mod h1:120d2SdlRYg8tRnZwsyNPeS+mWPn+YmNEzB7Bv/CDGE=
|
||||
github.com/go-delve/delve v1.5.0/go.mod h1:c6b3a1Gry6x8a4LGCe/CWzrocrfaHvkUxCj3k4bvSUQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v1.12.0 h1:/PtAHvnBY4Kqnx/xCQ3OIV9uYcSFGScBsWI3Oogeh6w=
|
||||
github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-dap v0.2.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.0-20170327083344-ded68f7a9561/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mmcloughlin/avo v0.0.0-20201105074841-5d2f697d268f/go.mod h1:6aKT4zZIrpGqB3RpFU14ByCSSyKY6LfJz4J/JJChHfI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/nicklaw5/helix v1.13.1 h1:J+DiwXMnYlY7paECl/wCEOUnOWGSs26XO/niDcskYHI=
|
||||
github.com/nicklaw5/helix v1.13.1/go.mod h1:XeeXY7oY5W+MVMu6wF4qGm8uvjZ1/Nss0FqprVkXKrg=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/peterh/liner v0.0.0-20170317030525-88609521dc4b/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc=
|
||||
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI=
|
||||
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.0-20170417170307-b6cb39589372/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v0.0.0-20170417173400-9e4c21054fa1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.0/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.starlark.net v0.0.0-20190702223751-32f345186213/go.mod h1:c1/X6cHgvdXj6pUlmWKMkuqRnW4K8x2vwt6JAaaircg=
|
||||
golang.org/x/arch v0.0.0-20190927153633-4e8777c89be4/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4=
|
||||
golang.org/x/arch v0.0.0-20201008161808-52c3e6f60cff/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201024042810-be3efd7ff127 h1:pZPp9+iYUqwYKLjht0SDBbRCRK/9gAXDy7pz5fRDpjo=
|
||||
golang.org/x/net v0.0.0-20201024042810-be3efd7ff127/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c h1:6L+uOeS3OQt/f4eFHXZcTxeZrGCuz+CLElgEBjbcTA4=
|
||||
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191127201027-ecd32218bd7f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201105001634-bc3cf281b174/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
@ -0,0 +1,134 @@
|
|||
package kv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// Time allowed to write a message to the peer.
|
||||
writeWait = 10 * time.Second
|
||||
|
||||
// Time allowed to read the next pong message from the peer.
|
||||
pongWait = 60 * time.Second
|
||||
|
||||
// Send pings to peer with this period. Must be less than pongWait.
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
|
||||
// Maximum message size allowed from peer.
|
||||
maxMessageSize = 512000
|
||||
)
|
||||
|
||||
var (
|
||||
newline = []byte{'\n'}
|
||||
space = []byte{' '}
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Client is a middleman between the websocket connection and the hub.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
|
||||
// The websocket connection.
|
||||
conn *websocket.Conn
|
||||
|
||||
// Buffered channel of outbound messages.
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// readPump pumps messages from the websocket connection to the hub.
|
||||
//
|
||||
// The application runs readPump in a per-connection goroutine. The application
|
||||
// ensures that there is at most one reader on a connection by executing all
|
||||
// reads from this goroutine.
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("[kv] error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
|
||||
c.hub.incoming <- rawMessage{c, message}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump pumps messages from the hub to the websocket connection.
|
||||
//
|
||||
// A goroutine running writePump is started for each connection. The
|
||||
// application ensures that there is at most one writer to a connection by
|
||||
// executing all writes from this goroutine.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
// The hub closed the channel.
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write(message)
|
||||
|
||||
// Add queued chat messages to the current websocket message.
|
||||
n := len(c.send)
|
||||
for i := 0; i < n; i++ {
|
||||
w.Write(newline)
|
||||
w.Write(<-c.send)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveWs handles websocket requests from the peer.
|
||||
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
|
||||
client.hub.register <- client
|
||||
|
||||
// Allow collection of memory referenced by the caller by doing all work in
|
||||
// new goroutines.
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
|
@ -0,0 +1,252 @@
|
|||
package kv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/strimertul/strimertul/logger"
|
||||
)
|
||||
|
||||
type rawMessage struct {
|
||||
Client *Client
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type clientList map[*Client]bool
|
||||
|
||||
// Hub maintains the set of active clients and broadcasts messages to the
|
||||
// clients.
|
||||
type Hub struct {
|
||||
// Registered clients.
|
||||
clients clientList
|
||||
|
||||
// Inbound messages from the clients.
|
||||
incoming chan rawMessage
|
||||
|
||||
// Register requests from the clients.
|
||||
register chan *Client
|
||||
|
||||
// Unregister requests from clients.
|
||||
unregister chan *Client
|
||||
|
||||
subscribers map[string]clientList
|
||||
listeners map[string][]chan<- string
|
||||
|
||||
db *badger.DB
|
||||
|
||||
logger logger.LogFn
|
||||
}
|
||||
|
||||
var json = jsoniter.ConfigDefault
|
||||
|
||||
func NewHub(db *badger.DB, logger logger.LogFn) *Hub {
|
||||
return &Hub{
|
||||
incoming: make(chan rawMessage, 10),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
clients: make(clientList),
|
||||
subscribers: make(map[string]clientList),
|
||||
listeners: make(map[string][]chan<- string),
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func sendErr(client *Client, err string) {
|
||||
msg, _ := json.Marshal(wsError{err})
|
||||
client.send <- msg
|
||||
}
|
||||
|
||||
func (h *Hub) ReadKey(key string) (string, error) {
|
||||
tx := h.db.NewTransaction(false)
|
||||
defer tx.Discard()
|
||||
|
||||
val, err := tx.Get([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
byt, err := val.ValueCopy(nil)
|
||||
return string(byt), err
|
||||
}
|
||||
|
||||
func (h *Hub) WriteKey(key string, data string) error {
|
||||
tx := h.db.NewTransaction(true)
|
||||
defer tx.Discard()
|
||||
|
||||
err := tx.Set([]byte(key), []byte(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.logger(logger.MTDebug, "(internal) modified key %s: %s", key, data)
|
||||
|
||||
// Notify subscribers
|
||||
if sublist, ok := h.subscribers[key]; ok {
|
||||
submsg, _ := json.Marshal(wsPush{"push", key, data})
|
||||
for client := range sublist {
|
||||
client.send <- submsg
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listener
|
||||
if sublist, ok := h.listeners[key]; ok {
|
||||
for _, listener := range sublist {
|
||||
listener <- data
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hub) SubscribeKey(key string, ch chan<- string) {
|
||||
h.listeners[key] = append(h.listeners[key], ch)
|
||||
}
|
||||
|
||||
func (h *Hub) handleCmd(client *Client, message rawMessage) {
|
||||
var msg wsRequest
|
||||
err := json.Unmarshal(message.Data, &msg)
|
||||
if err != nil {
|
||||
sendErr(message.Client, fmt.Sprintf("invalid message format: %v", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.CmdName {
|
||||
case CmdReadKey:
|
||||
// Check params
|
||||
key, ok := msg.Data["key"].(string)
|
||||
if !ok {
|
||||
sendErr(client, "invalid 'key' param")
|
||||
return
|
||||
}
|
||||
|
||||
h.db.View(func(tx *badger.Txn) error {
|
||||
val, err := tx.Get([]byte(key))
|
||||
if err != nil {
|
||||
if err == badger.ErrKeyNotFound {
|
||||
msg, _ := json.Marshal(wsGenericResponse{"response", true, string(message.Data), string("")})
|
||||
client.send <- msg
|
||||
h.logger(logger.MTWarning, "get for inexistant key: %s", key)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
byt, err := val.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, _ := json.Marshal(wsGenericResponse{"response", true, string(message.Data), string(byt)})
|
||||
client.send <- msg
|
||||
h.logger(logger.MTDebug, "get key %s: %s", key, message.Data)
|
||||
return nil
|
||||
})
|
||||
case CmdWriteKey:
|
||||
// Check params
|
||||
key, ok := msg.Data["key"].(string)
|
||||
if !ok {
|
||||
sendErr(client, "invalid 'key' param")
|
||||
return
|
||||
}
|
||||
data, ok := msg.Data["data"].(string)
|
||||
if !ok {
|
||||
sendErr(client, "invalid 'key' param")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.db.Update(func(tx *badger.Txn) error {
|
||||
return tx.Set([]byte(key), []byte(data))
|
||||
})
|
||||
if err != nil {
|
||||
sendErr(client, fmt.Sprintf("update failed: %v", err.Error()))
|
||||
}
|
||||
// Send OK response
|
||||
msg, _ := json.Marshal(wsEmptyResponse{"response", true, string(message.Data)})
|
||||
client.send <- msg
|
||||
|
||||
h.logger(logger.MTDebug, "modified key %s: %s", key, data)
|
||||
|
||||
// Notify subscribers
|
||||
if sublist, ok := h.subscribers[key]; ok {
|
||||
submsg, _ := json.Marshal(wsPush{"push", key, data})
|
||||
for client := range sublist {
|
||||
client.send <- submsg
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listener
|
||||
if sublist, ok := h.listeners[key]; ok {
|
||||
for _, listener := range sublist {
|
||||
listener <- data
|
||||
}
|
||||
}
|
||||
case CmdSubscribeKey:
|
||||
// Check params
|
||||
key, ok := msg.Data["key"].(string)
|
||||
if !ok {
|
||||
sendErr(client, "invalid 'key' param")
|
||||
return
|
||||
}
|
||||
_, ok = h.subscribers[key]
|
||||
if !ok {
|
||||
h.subscribers[key] = make(clientList)
|
||||
}
|
||||
h.subscribers[key][client] = true
|
||||
h.logger(logger.MTDebug, "%s subscribed to %s", client.conn.RemoteAddr(), key)
|
||||
// Send OK response
|
||||
msg, _ := json.Marshal(wsEmptyResponse{"response", true, string(message.Data)})
|
||||
client.send <- msg
|
||||
case CmdUnsubscribeKey:
|
||||
// Check params
|
||||
key, ok := msg.Data["key"].(string)
|
||||
if !ok {
|
||||
sendErr(client, "invalid 'key' param")
|
||||
return
|
||||
}
|
||||
_, ok = h.subscribers[key]
|
||||
if !ok {
|
||||
sendErr(client, "subscription does not exist")
|
||||
return
|
||||
}
|
||||
if _, ok := h.subscribers[key][client]; !ok {
|
||||
sendErr(client, "you are not subscribed to this")
|
||||
return
|
||||
}
|
||||
delete(h.subscribers[key], client)
|
||||
h.logger(logger.MTDebug, "%s unsubscribed to %s", client.conn.RemoteAddr(), key)
|
||||
// Send OK response
|
||||
msg, _ := json.Marshal(wsEmptyResponse{"response", true, string(message.Data)})
|
||||
client.send <- msg
|
||||
default:
|
||||
sendErr(client, "unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Run() {
|
||||
h.logger(logger.MTNotice, "running")
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.clients[client] = true
|
||||
case client := <-h.unregister:
|
||||
// Make sure client is considered active first
|
||||
if _, ok := h.clients[client]; !ok {
|
||||
continue
|
||||
}
|
||||
// Check for subscriptions
|
||||
for key := range h.subscribers {
|
||||
if _, ok := h.subscribers[key][client]; ok {
|
||||
delete(h.subscribers[key], client)
|
||||
}
|
||||
}
|
||||
// Delete entry and close channel
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
case message := <-h.incoming:
|
||||
h.handleCmd(message.Client, message)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package kv
|
||||
|
||||
// Commands
|
||||
const (
|
||||
CmdReadKey = "kget"
|
||||
CmdWriteKey = "kset"
|
||||
CmdSubscribeKey = "ksub"
|
||||
CmdUnsubscribeKey = "kunsub"
|
||||
)
|
||||
|
||||
type wsRequest struct {
|
||||
CmdName string `json:"command"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type wsError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type wsGenericResponse struct {
|
||||
CmdType string `json:"type"`
|
||||
Ok bool `json:"ok"`
|
||||
Cmd string `json:"cmd"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type wsEmptyResponse struct {
|
||||
CmdType string `json:"type"`
|
||||
Ok bool `json:"ok"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
type wsPush struct {
|
||||
CmdType string `json:"type"`
|
||||
Key string `json:"key"`
|
||||
NewValue string `json:"new_value"`
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package logger
|
||||
|
||||
type MessageType int
|
||||
|
||||
const (
|
||||
MTDebug MessageType = iota
|
||||
MTNotice MessageType = iota
|
||||
MTWarning MessageType = iota
|
||||
MTError MessageType = iota
|
||||
)
|
||||
|
||||
type LogFn func(level MessageType, fmt string, args ...interface{})
|
||||
|
||||
func (m MessageType) String() string {
|
||||
switch m {
|
||||
case MTDebug:
|
||||
return "debug"
|
||||
case MTNotice:
|
||||
return "notice"
|
||||
case MTWarning:
|
||||
return "warning"
|
||||
case MTError:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/strimertul/strimertul/kv"
|
||||
"github.com/strimertul/strimertul/logger"
|
||||
"github.com/strimertul/strimertul/modules"
|
||||
"github.com/strimertul/strimertul/modules/loyalty"
|
||||
"github.com/strimertul/strimertul/stulbe"
|
||||
"github.com/strimertul/strimertul/twitchbot"
|
||||
"github.com/strimertul/strimertul/utils"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
const AppTitle = "strimertül"
|
||||
|
||||
const AppHeader = `
|
||||
_ _ _ O O _
|
||||
__| |_ _ _(_)_ __ ___ _ _| |_ _ _| |
|
||||
(_-< _| '_| | ' \/ -_) '_| _| || | |
|
||||
/__/\__|_| |_|_|_|_\___|_| \__|\_,_|_| `
|
||||
|
||||
const DefaultBind = "localhost:4337"
|
||||
|
||||
//go:embed frontend/dist/*
|
||||
var frontend embed.FS
|
||||
|
||||
func wrapLogger(module string) logger.LogFn {
|
||||
return func(level logger.MessageType, format string, args ...interface{}) {
|
||||
args = append([]interface{}{module, level}, args...)
|
||||
log.Printf("[%s/%s] "+format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dbfile := flag.String("dbdir", "data", "Path to strimertul database dir")
|
||||
flag.Parse()
|
||||
|
||||
fmt.Println(AppHeader)
|
||||
|
||||
var json = jsoniter.ConfigDefault
|
||||
|
||||
// Loading routine
|
||||
db, err := badger.Open(badger.DefaultOptions(*dbfile))
|
||||
failOnError(err, "Could not open DB")
|
||||
defer db.Close()
|
||||
|
||||
// Check if onboarding was completed
|
||||
var moduleConfig modules.ModuleConfig
|
||||
err = utils.DBGetJSON(db, modules.ModuleConfigKey, &moduleConfig)
|
||||
if err != nil {
|
||||
if err == badger.ErrKeyNotFound {
|
||||
moduleConfig = modules.ModuleConfig{CompletedOnboarding: false}
|
||||
} else {
|
||||
fatalError(err, "Could not read from DB")
|
||||
}
|
||||
}
|
||||
|
||||
if !moduleConfig.CompletedOnboarding {
|
||||
// Initialize DB as empty and default endpoint
|
||||
err := db.Update(func(t *badger.Txn) error {
|
||||
encoded, err := json.Marshal(modules.HTTPServerConfig{
|
||||
Bind: DefaultBind,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = t.Set([]byte(modules.HTTPServerConfigKey), encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onboardingConf := modules.ModuleConfig{
|
||||
EnableKV: true,
|
||||
EnableStaticServer: false,
|
||||
EnableTwitchbot: false,
|
||||
EnableStulbe: false,
|
||||
CompletedOnboarding: true,
|
||||
}
|
||||
encoded, err = json.Marshal(onboardingConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Set([]byte(modules.ModuleConfigKey), encoded)
|
||||
})
|
||||
failOnError(err, "could not save webserver config")
|
||||
fmt.Printf("It appears this is your first time running %s! Please go to http://%s and make sure to configure anything you want!\n\n", AppTitle, DefaultBind)
|
||||
}
|
||||
|
||||
// Initialize KV (required)
|
||||
hub := kv.NewHub(db, wrapLogger("kv"))
|
||||
go hub.Run()
|
||||
|
||||
// Get HTTP config
|
||||
var httpConfig modules.HTTPServerConfig
|
||||
failOnError(utils.DBGetJSON(db, modules.HTTPServerConfigKey, &httpConfig), "Could not retrieve HTTP server config")
|
||||
|
||||
// Get Stulbe config, if enabled
|
||||
var stulbeClient *stulbe.StulbeClient = nil
|
||||
if moduleConfig.EnableStulbe {
|
||||
var stulbeConfig modules.StulbeConfig
|
||||
failOnError(utils.DBGetJSON(db, modules.StulbeConfigKey, &stulbeConfig), "Could not retrieve Stulbe config")
|
||||
stulbeClient = stulbe.NewClient(stulbeConfig.Endpoint)
|
||||
}
|
||||
|
||||
var loyaltyManager *loyalty.Manager
|
||||
loyaltyLogger := wrapLogger("loyalty")
|
||||
if moduleConfig.EnableLoyalty {
|
||||
loyaltyManager, err = loyalty.NewManager(db, hub, loyaltyLogger)
|
||||
if err != nil {
|
||||
fatalError(err, "Could not initialize loyalty system")
|
||||
}
|
||||
}
|
||||
|
||||
//TODO Refactor this to something sane
|
||||
if moduleConfig.EnableTwitchbot {
|
||||
// Create logger
|
||||
twitchLogger := wrapLogger("twitchbot")
|
||||
|
||||
// Get Twitchbot config
|
||||
var twitchConfig modules.TwitchBotConfig
|
||||
failOnError(utils.DBGetJSON(db, modules.TwitchBotConfigKey, &twitchConfig), "Could not retrieve twitch bot config")
|
||||
|
||||
bot := twitchbot.NewBot(twitchConfig.Username, twitchConfig.Token, twitchLogger)
|
||||
|
||||
if moduleConfig.EnableLoyalty {
|
||||
bot.Loyalty = loyaltyManager
|
||||
bot.SetBanList(loyaltyManager.Config.BanList)
|
||||
}
|
||||
|
||||
bot.Client.Join(twitchConfig.Channel)
|
||||
|
||||
if moduleConfig.EnableLoyalty {
|
||||
bot.Client.OnConnect(func() {
|
||||
if loyaltyManager.Config.Points.Interval > 0 {
|
||||
go func() {
|
||||
twitchLogger(logger.MTNotice, "Loyalty poll started")
|
||||
for {
|
||||
// Wait for next poll
|
||||
time.Sleep(time.Duration(loyaltyManager.Config.Points.Interval) * time.Second)
|
||||
|
||||
// Check if streamer is online, if possible
|
||||
streamOnline := true
|
||||
if loyaltyManager.Config.LiveCheck && moduleConfig.EnableStulbe {
|
||||
status, err := stulbeClient.StreamStatus(twitchConfig.Channel)
|
||||
if err != nil {
|
||||
twitchLogger(logger.MTError, "Error checking stream status: %s", err.Error())
|
||||
} else {
|
||||
streamOnline = status != nil
|
||||
}
|
||||
}
|
||||
|
||||
// If stream is confirmed offline, don't give points away!
|
||||
if !streamOnline {
|
||||
twitchLogger(logger.MTNotice, "Loyalty poll active but stream is offline!")
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user list
|
||||
users, err := bot.Client.Userlist(twitchConfig.Channel)
|
||||
if err != nil {
|
||||
twitchLogger(logger.MTError, "Error listing users: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Iterate for each user in the list
|
||||
pointsToGive := make(map[string]int64)
|
||||
for _, user := range users {
|
||||
// Check if user is blocked
|
||||
if bot.IsBanned(user) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user was active (chatting) for the bonus dingus
|
||||
award := loyaltyManager.Config.Points.Amount
|
||||
if bot.IsActive(user) {
|
||||
award += loyaltyManager.Config.Points.ActivityBonus
|
||||
}
|
||||
|
||||
// Add to point pool if already on it, otherwise initialize
|
||||
pointsToGive[user] = award
|
||||
}
|
||||
|
||||
bot.ResetActivity()
|
||||
|
||||
// If changes were made, save the pool!
|
||||
if len(users) > 0 {
|
||||
loyaltyManager.GivePoints(pointsToGive)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
failOnError(bot.Connect(), "connection failed")
|
||||
}()
|
||||
}
|
||||
|
||||
// Create logger and endpoints
|
||||
httpLogger := wrapLogger("http")
|
||||
|
||||
fedir, _ := fs.Sub(frontend, "frontend/dist")
|
||||
http.Handle("/ui/", http.StripPrefix("/ui/", FileServerWithDefault(http.FS(fedir))))
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
kv.ServeWs(hub, w, r)
|
||||
})
|
||||
if moduleConfig.EnableStaticServer {
|
||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(httpConfig.Path))))
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Second) // THIS IS STUPID
|
||||
browser.OpenURL(fmt.Sprintf("http://%s/ui", httpConfig.Bind))
|
||||
}()
|
||||
|
||||
// Start HTTP server
|
||||
httpLogger(logger.MTNotice, "serving %s", httpConfig.Path)
|
||||
fatalError(http.ListenAndServe(httpConfig.Bind, nil), "HTTP server died unexepectedly")
|
||||
}
|
||||
|
||||
func failOnError(err error, text string) {
|
||||
if err != nil {
|
||||
fatalError(err, text)
|
||||
}
|
||||
}
|
||||
|
||||
func fatalError(err error, text string) {
|
||||
log.Fatalf("FATAL ERROR OCCURRED: %s\n\n%s", text, err.Error())
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package loyalty
|
||||
|
||||
import "time"
|
||||
|
||||
const ConfigKey = "loyalty/config"
|
||||
|
||||
type Config struct {
|
||||
Currency string `json:"currency"`
|
||||
LiveCheck bool `json:"enable_live_check"`
|
||||
Points struct {
|
||||
Interval int64 `json:"interval"` // in seconds!
|
||||
Amount int64 `json:"amount"`
|
||||
ActivityBonus int64 `json:"activity_bonus"`
|
||||
} `json:"points"`
|
||||
BanList []string `json:"banlist"`
|
||||
}
|
||||
|
||||
const RewardsKey = "loyalty/rewards"
|
||||
|
||||
type RewardStorage []Reward
|
||||
|
||||
const GoalsKey = "loyalty/goals"
|
||||
|
||||
type GoalStorage []Goal
|
||||
|
||||
type Reward struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Price int64 `json:"price"`
|
||||
CustomRequest string `json:"required_info,omit_empty"`
|
||||
}
|
||||
|
||||
type Goal struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
TotalGoal int64 `json:"total"`
|
||||
Contributed int64 `json:"contributed"`
|
||||
Contributors map[string]int64 `json:"contributors"`
|
||||
}
|
||||
|
||||
const PointsKey = "loyalty/users"
|
||||
|
||||
type PointStorage map[string]int64
|
||||
|
||||
const QueueKey = "loyalty/redeem-queue"
|
||||
|
||||
type RedeemQueueStorage []Redeem
|
||||
|
||||
type Redeem struct {
|
||||
User string `json:"user"`
|
||||
Reward Reward `json:"reward"`
|
||||
When time.Time `json:"when"`
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
package loyalty
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/strimertul/strimertul/kv"
|
||||
"github.com/strimertul/strimertul/logger"
|
||||
"github.com/strimertul/strimertul/utils"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"github.com/dgraph-io/badger/v3/pb"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
Config Config
|
||||
Points PointStorage
|
||||
Rewards RewardStorage
|
||||
Goals GoalStorage
|
||||
RedeemQueue RedeemQueueStorage
|
||||
|
||||
hub *kv.Hub
|
||||
logger logger.LogFn
|
||||
}
|
||||
|
||||
func NewManager(db *badger.DB, hub *kv.Hub, log logger.LogFn) (*Manager, error) {
|
||||
manager := &Manager{
|
||||
logger: log,
|
||||
hub: hub,
|
||||
}
|
||||
// Ger data from DB
|
||||
if err := utils.DBGetJSON(db, ConfigKey, &manager.Config); err != nil {
|
||||
if err == badger.ErrKeyNotFound {
|
||||
log(logger.MTWarning, "Missing configuration for loyalty (but it's enabled). Please make sure to set it up properly!")
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := utils.DBGetJSON(db, PointsKey, &manager.Points); err != nil {
|
||||
if err == badger.ErrKeyNotFound {
|
||||
manager.Points = make(PointStorage)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := utils.DBGetJSON(db, RewardsKey, &manager.Rewards); err != nil {
|
||||
if err != badger.ErrKeyNotFound {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := utils.DBGetJSON(db, GoalsKey, &manager.Goals); err != nil {
|
||||
if err != badger.ErrKeyNotFound {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := utils.DBGetJSON(db, QueueKey, &manager.RedeemQueue); err != nil {
|
||||
if err != badger.ErrKeyNotFound {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe for changes
|
||||
go func() {
|
||||
db.Subscribe(context.Background(), manager.update, []byte("loyalty/"))
|
||||
}()
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *Manager) update(kvs *pb.KVList) error {
|
||||
for _, kv := range kvs.Kv {
|
||||
var err error
|
||||
switch string(kv.Key) {
|
||||
case ConfigKey:
|
||||
err = jsoniter.ConfigFastest.Unmarshal(kv.Value, &m.Config)
|
||||
case PointsKey:
|
||||
err = jsoniter.ConfigFastest.Unmarshal(kv.Value, &m.Points)
|
||||
case GoalsKey:
|
||||
err = jsoniter.ConfigFastest.Unmarshal(kv.Value, &m.Goals)
|
||||
case RewardsKey:
|
||||
err = jsoniter.ConfigFastest.Unmarshal(kv.Value, &m.Rewards)
|
||||
case QueueKey:
|
||||
err = jsoniter.ConfigFastest.Unmarshal(kv.Value, &m.RedeemQueue)
|
||||
}
|
||||
if err != nil {
|
||||
m.logger(logger.MTError, "Subscribe error: invalid JSON received on key %s: %s", kv.Key, err.Error())
|
||||
} else {
|
||||
m.logger(logger.MTNotice, "Updated key %s", kv.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SavePoints() error {
|
||||
data, _ := jsoniter.ConfigFastest.Marshal(m.Points)
|
||||
return m.hub.WriteKey(PointsKey, string(data))
|
||||
}
|
||||
|
||||
func (m *Manager) GivePoints(pointsToGive map[string]int64) error {
|
||||
// Add points to each user
|
||||
for user, points := range pointsToGive {
|
||||
m.Points[user] += points
|
||||
}
|
||||
|
||||
// Save points
|
||||
return m.SavePoints()
|
||||
}
|
||||
|
||||
func (m *Manager) TakePoints(pointsToTake map[string]int64) error {
|
||||
// Add points to each user
|
||||
for user, points := range pointsToTake {
|
||||
m.Points[user] -= points
|
||||
}
|
||||
|
||||
// Save points
|
||||
return m.SavePoints()
|
||||
}
|
||||
|
||||
func (m *Manager) SaveQueue() error {
|
||||
data, _ := jsoniter.ConfigFastest.Marshal(m.RedeemQueue)
|
||||
return m.hub.WriteKey(QueueKey, string(data))
|
||||
}
|
||||
|
||||
func (m *Manager) AddRedeem(user string, reward Reward) error {
|
||||
m.RedeemQueue = append(m.RedeemQueue, Redeem{
|
||||
When: time.Now(),
|
||||
User: user,
|
||||
Reward: reward,
|
||||
})
|
||||
|
||||
// Save points
|
||||
return m.SaveQueue()
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package modules
|
||||
|
||||
const ModuleConfigKey = "stul-meta/modules"
|
||||
|
||||
type ModuleConfig struct {
|
||||
CompletedOnboarding bool `json:"configured"`
|
||||
EnableKV bool `json:"kv"`
|
||||
EnableStaticServer bool `json:"static"`
|
||||
EnableTwitchbot bool `json:"twitchbot"`
|
||||
EnableStulbe bool `json:"stulbe"`
|
||||
EnableLoyalty bool `json:"loyalty"`
|
||||
}
|
||||
|
||||
const HTTPServerConfigKey = "http/config"
|
||||
|
||||
type HTTPServerConfig struct {
|
||||
Bind string `json:"bind"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
const TwitchBotConfigKey = "twitchbot/config"
|
||||
|
||||
type TwitchBotConfig struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"oauth"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
const StulbeConfigKey = "stulbe/config"
|
||||
|
||||
type StulbeConfig struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Token string `json:"token"`
|
||||
EnableLoyalty bool `json:"enable_loyalty"`
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
// FROM https://gist.github.com/lummie/91cd1c18b2e32fa9f316862221a6fd5c
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FileServerWithDefault(root http.FileSystem) http.Handler {
|
||||
fs := http.FileServer(root)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
//make sure the url path starts with /
|
||||
upath := r.URL.Path
|
||||
if !strings.HasPrefix(upath, "/") {
|
||||
upath = "/" + upath
|
||||
r.URL.Path = upath
|
||||
}
|
||||
upath = path.Clean(upath)
|
||||
|
||||
// attempt to open the file via the http.FileSystem
|
||||
f, err := root.Open(upath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Revert to homepage
|
||||
r.URL.Path = "/"
|
||||
}
|
||||
}
|
||||
|
||||
// close if successfully opened
|
||||
if err == nil {
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// default serve
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package stulbe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/nicklaw5/helix"
|
||||
)
|
||||
|
||||
type StulbeClient struct {
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
func NewClient(endpoint string) *StulbeClient {
|
||||
return &StulbeClient{
|
||||
Endpoint: endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StulbeClient) StreamStatus(streamer string) (*helix.Stream, error) {
|
||||
resp, err := http.Get(fmt.Sprintf("%s/api/stul/stream/%s/status", s.Endpoint, streamer))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var streams []helix.Stream
|
||||
err = jsoniter.ConfigFastest.NewDecoder(resp.Body).Decode(&streams)
|
||||
if len(streams) < 1 {
|
||||
return nil, err
|
||||
}
|
||||
return &streams[0], err
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package twitchbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
irc "github.com/gempir/go-twitch-irc/v2"
|
||||
"github.com/strimertul/strimertul/logger"
|
||||
"github.com/strimertul/strimertul/modules/loyalty"
|
||||
)
|
||||
|
||||
type TwitchBot struct {
|
||||
Client *irc.Client
|
||||
|
||||
username string
|
||||
logger logger.LogFn
|
||||
lastMessage time.Time
|
||||
activeUsers map[string]bool
|
||||
banlist map[string]bool
|
||||
|
||||
// Module specific vars
|
||||
Loyalty *loyalty.Manager
|
||||
}
|
||||
|
||||
func NewBot(username string, token string, log logger.LogFn) *TwitchBot {
|
||||
// Create client
|
||||
client := irc.NewClient(username, token)
|
||||
|
||||
bot := &TwitchBot{
|
||||
Client: client,
|
||||
username: strings.ToLower(username), // Normalize username
|
||||
logger: log,
|
||||
lastMessage: time.Now(),
|
||||
activeUsers: make(map[string]bool),
|
||||
banlist: make(map[string]bool),
|
||||
}
|
||||
|
||||
client.OnPrivateMessage(func(message irc.PrivateMessage) {
|
||||
bot.logger(logger.MTDebug, "MSG: <%s> %s", message.User.Name, message.Message)
|
||||
// Ignore messages for a while or twitch will get mad!
|
||||
if message.Time.Before(bot.lastMessage.Add(time.Second * 2)) {
|
||||
bot.logger(logger.MTDebug, "message received too soon, ignoring")
|
||||
return
|
||||
}
|
||||
bot.activeUsers[message.User.Name] = true
|
||||
|
||||
// Check if it's a command
|
||||
if strings.HasPrefix(message.Message, "!") {
|
||||
// Run through supported commands
|
||||
for cmd, data := range commands {
|
||||
if strings.HasPrefix(message.Message, cmd) {
|
||||
data.Handler(bot, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.OnUserJoinMessage(func(message irc.UserJoinMessage) {
|
||||
if strings.ToLower(message.User) == bot.username {
|
||||
bot.logger(logger.MTNotice, "Joined %s", message.Channel)
|
||||
} else {
|
||||
bot.logger(logger.MTDebug, "%s joined %s", message.User, message.Channel)
|
||||
}
|
||||
})
|
||||
client.OnUserPartMessage(func(message irc.UserPartMessage) {
|
||||
if strings.ToLower(message.User) == bot.username {
|
||||
bot.logger(logger.MTNotice, "Left %s", message.Channel)
|
||||
} else {
|
||||
bot.logger(logger.MTDebug, "%s left %s", message.User, message.Channel)
|
||||
}
|
||||
})
|
||||
|
||||
return bot
|
||||
}
|
||||
|
||||
func (b *TwitchBot) SetBanList(banned []string) {
|
||||
b.banlist = make(map[string]bool)
|
||||
for _, usr := range banned {
|
||||
b.banlist[usr] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TwitchBot) IsBanned(user string) bool {
|
||||
banned, ok := b.banlist[user]
|
||||
return ok && banned
|
||||
}
|
||||
|
||||
func (b *TwitchBot) IsActive(user string) bool {
|
||||
active, ok := b.activeUsers[user]
|
||||
return ok && active
|
||||
}
|
||||
|
||||
func (b *TwitchBot) ResetActivity() {
|
||||
b.activeUsers = make(map[string]bool)
|
||||
}
|
||||
|
||||
func (b *TwitchBot) Connect() error {
|
||||
return b.Client.Connect()
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package twitchbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
irc "github.com/gempir/go-twitch-irc/v2"
|
||||
"github.com/strimertul/strimertul/logger"
|
||||
)
|
||||
|
||||
type AccessLevelType string
|
||||
|
||||
const (
|
||||
ALTEveryone AccessLevelType = "everyone"
|
||||
ALTVIP AccessLevelType = "vip"
|
||||
ALTModerators AccessLevelType = "moderators"
|
||||
ALTStreamer AccessLevelType = "streamer"
|
||||
)
|
||||
|
||||
type BotCommandHandler func(bot *TwitchBot, message irc.PrivateMessage)
|
||||
|
||||
type BotCommand struct {
|
||||
Description string
|
||||
Usage string
|
||||
AccessLevel AccessLevelType
|
||||
Handler BotCommandHandler
|
||||
}
|
||||
|
||||
var commands = map[string]BotCommand{
|
||||
"!redeem": {
|
||||
Description: "Redeem a reward with loyalty points",
|
||||
Usage: "!redeem reward-id",
|
||||
AccessLevel: ALTEveryone,
|
||||
Handler: cmdRedeem,
|
||||
},
|
||||
}
|
||||
|
||||
func cmdRedeem(bot *TwitchBot, message irc.PrivateMessage) {
|
||||
parts := strings.Fields(message.Message)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
redeemID := parts[1]
|
||||
|
||||
// Find reward
|
||||
for _, reward := range bot.Loyalty.Rewards {
|
||||
if reward.ID != redeemID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user balance
|
||||
balance, ok := bot.Loyalty.Points[message.User.Name]
|
||||
if !ok {
|
||||
balance = 0
|
||||
}
|
||||
|
||||
// Check if user can afford the reward
|
||||
if balance-reward.Price < 0 {
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("I'm sorry %s but you cannot afford this (have %d %s, need %d)", message.User.DisplayName, balance, bot.Loyalty.Config.Currency, reward.Price))
|
||||
return
|
||||
}
|
||||
|
||||
// Perform redeem
|
||||
if err := bot.Loyalty.AddRedeem(message.User.Name, reward); err != nil {
|
||||
bot.logger(logger.MTError, "error while adding redeem: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove points from user
|
||||
if err := bot.Loyalty.TakePoints(map[string]int64{message.User.Name: reward.Price}); err != nil {
|
||||
bot.logger(logger.MTError, "error while taking points for redeem: %s", err.Error())
|
||||
}
|
||||
|
||||
bot.Client.Say(message.Channel, fmt.Sprintf("%s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, bot.Loyalty.Points[message.User.Name], bot.Loyalty.Config.Currency))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
)
|
||||
|
||||
func GetJSONTx(t *badger.Txn, key string, dst interface{}) error {
|
||||
item, err := t.Get([]byte(key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byt, err := item.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(byt, dst)
|
||||
}
|
||||
|
||||
func DBGetJSON(db *badger.DB, key string, dst interface{}) error {
|
||||
return db.View(func(t *badger.Txn) error {
|
||||
return GetJSONTx(t, key, dst)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue