Upload folder using huggingface_hub
Browse files- .dockerignore +7 -0
- .gitignore +9 -0
- Dockerfile +18 -0
- LICENSE +662 -0
- README.md +166 -8
- TODO.md +20 -0
- requirements.txt +7 -0
- tgfs/__init__.py +2 -0
- tgfs/__main__.py +143 -0
- tgfs/app.py +13 -0
- tgfs/config.py +127 -0
- tgfs/database/__init__.py +45 -0
- tgfs/database/database.py +171 -0
- tgfs/database/mongodb/__init__.py +63 -0
- tgfs/database/mongodb/file.py +226 -0
- tgfs/database/mongodb/group.py +159 -0
- tgfs/database/mongodb/user.py +95 -0
- tgfs/database/mongodb/utils.py +52 -0
- tgfs/database/mysql/__init__.py +75 -0
- tgfs/database/mysql/file.py +339 -0
- tgfs/database/mysql/group.py +207 -0
- tgfs/database/mysql/schema.sql +63 -0
- tgfs/database/mysql/user.py +114 -0
- tgfs/database/mysql/utils.py +127 -0
- tgfs/info.py +9 -0
- tgfs/log.py +28 -0
- tgfs/paralleltransfer.py +234 -0
- tgfs/patches/__init__.py +4 -0
- tgfs/plugins/admin.py +227 -0
- tgfs/plugins/callback.py +279 -0
- tgfs/plugins/custom.py +80 -0
- tgfs/plugins/files.py +175 -0
- tgfs/plugins/message.py +115 -0
- tgfs/routes.py +109 -0
- tgfs/telegram.py +86 -0
- tgfs/utils/cache_util.py +76 -0
- tgfs/utils/config_utils.py +73 -0
- tgfs/utils/patches.py +18 -0
- tgfs/utils/translation.py +226 -0
- tgfs/utils/types.py +101 -0
- tgfs/utils/utils.py +162 -0
.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
venv
|
| 6 |
+
.env
|
| 7 |
+
db.sqlite3
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.session
|
| 2 |
+
*.session-journal
|
| 3 |
+
*.env
|
| 4 |
+
test.py
|
| 5 |
+
__pycache__
|
| 6 |
+
|
| 7 |
+
tgfs/patches/*
|
| 8 |
+
!tgfs/patches/__init__.py
|
| 9 |
+
!tgfs/patches/README.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.9-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y gcc \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
# Hugging Face deployment requirements
|
| 14 |
+
ENV PORT=7860
|
| 15 |
+
ENV HOST=0.0.0.0
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["python", "-m", "tgfs"]
|
LICENSE
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GNU AFFERO GENERAL PUBLIC LICENSE
|
| 2 |
+
Version 3, 19 November 2007
|
| 3 |
+
|
| 4 |
+
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
| 5 |
+
Everyone is permitted to copy and distribute verbatim copies
|
| 6 |
+
of this license document, but changing it is not allowed.
|
| 7 |
+
|
| 8 |
+
Preamble
|
| 9 |
+
|
| 10 |
+
The GNU Affero General Public License is a free, copyleft license for
|
| 11 |
+
software and other kinds of works, specifically designed to ensure
|
| 12 |
+
cooperation with the community in the case of network server software.
|
| 13 |
+
|
| 14 |
+
The licenses for most software and other practical works are designed
|
| 15 |
+
to take away your freedom to share and change the works. By contrast,
|
| 16 |
+
our General Public Licenses are intended to guarantee your freedom to
|
| 17 |
+
share and change all versions of a program--to make sure it remains free
|
| 18 |
+
software for all its users.
|
| 19 |
+
|
| 20 |
+
When we speak of free software, we are referring to freedom, not
|
| 21 |
+
price. Our General Public Licenses are designed to make sure that you
|
| 22 |
+
have the freedom to distribute copies of free software (and charge for
|
| 23 |
+
them if you wish), that you receive source code or can get it if you
|
| 24 |
+
want it, that you can change the software or use pieces of it in new
|
| 25 |
+
free programs, and that you know you can do these things.
|
| 26 |
+
|
| 27 |
+
Developers that use our General Public Licenses protect your rights
|
| 28 |
+
with two steps: (1) assert copyright on the software, and (2) offer
|
| 29 |
+
you this License which gives you legal permission to copy, distribute
|
| 30 |
+
and/or modify the software.
|
| 31 |
+
|
| 32 |
+
A secondary benefit of defending all users' freedom is that
|
| 33 |
+
improvements made in alternate versions of the program, if they
|
| 34 |
+
receive widespread use, become available for other developers to
|
| 35 |
+
incorporate. Many developers of free software are heartened and
|
| 36 |
+
encouraged by the resulting cooperation. However, in the case of
|
| 37 |
+
software used on network servers, this result may fail to come about.
|
| 38 |
+
The GNU General Public License permits making a modified version and
|
| 39 |
+
letting the public access it on a server without ever releasing its
|
| 40 |
+
source code to the public.
|
| 41 |
+
|
| 42 |
+
The GNU Affero General Public License is designed specifically to
|
| 43 |
+
ensure that, in such cases, the modified source code becomes available
|
| 44 |
+
to the community. It requires the operator of a network server to
|
| 45 |
+
provide the source code of the modified version running there to the
|
| 46 |
+
users of that server. Therefore, public use of a modified version, on
|
| 47 |
+
a publicly accessible server, gives the public access to the source
|
| 48 |
+
code of the modified version.
|
| 49 |
+
|
| 50 |
+
An older license, called the Affero General Public License and
|
| 51 |
+
published by Affero, was designed to accomplish similar goals. This is
|
| 52 |
+
a different license, not a version of the Affero GPL, but Affero has
|
| 53 |
+
released a new version of the Affero GPL which permits relicensing under
|
| 54 |
+
this license.
|
| 55 |
+
|
| 56 |
+
The precise terms and conditions for copying, distribution and
|
| 57 |
+
modification follow.
|
| 58 |
+
|
| 59 |
+
TERMS AND CONDITIONS
|
| 60 |
+
|
| 61 |
+
0. Definitions.
|
| 62 |
+
|
| 63 |
+
"This License" refers to version 3 of the GNU Affero General Public License.
|
| 64 |
+
|
| 65 |
+
"Copyright" also means copyright-like laws that apply to other kinds of
|
| 66 |
+
works, such as semiconductor masks.
|
| 67 |
+
|
| 68 |
+
"The Program" refers to any copyrightable work licensed under this
|
| 69 |
+
License. Each licensee is addressed as "you". "Licensees" and
|
| 70 |
+
"recipients" may be individuals or organizations.
|
| 71 |
+
|
| 72 |
+
To "modify" a work means to copy from or adapt all or part of the work
|
| 73 |
+
in a fashion requiring copyright permission, other than the making of an
|
| 74 |
+
exact copy. The resulting work is called a "modified version" of the
|
| 75 |
+
earlier work or a work "based on" the earlier work.
|
| 76 |
+
|
| 77 |
+
A "covered work" means either the unmodified Program or a work based
|
| 78 |
+
on the Program.
|
| 79 |
+
|
| 80 |
+
To "propagate" a work means to do anything with it that, without
|
| 81 |
+
permission, would make you directly or secondarily liable for
|
| 82 |
+
infringement under applicable copyright law, except executing it on a
|
| 83 |
+
computer or modifying a private copy. Propagation includes copying,
|
| 84 |
+
distribution (with or without modification), making available to the
|
| 85 |
+
public, and in some countries other activities as well.
|
| 86 |
+
|
| 87 |
+
To "convey" a work means any kind of propagation that enables other
|
| 88 |
+
parties to make or receive copies. Mere interaction with a user through
|
| 89 |
+
a computer network, with no transfer of a copy, is not conveying.
|
| 90 |
+
|
| 91 |
+
An interactive user interface displays "Appropriate Legal Notices"
|
| 92 |
+
to the extent that it includes a convenient and prominently visible
|
| 93 |
+
feature that (1) displays an appropriate copyright notice, and (2)
|
| 94 |
+
tells the user that there is no warranty for the work (except to the
|
| 95 |
+
extent that warranties are provided), that licensees may convey the
|
| 96 |
+
work under this License, and how to view a copy of this License. If
|
| 97 |
+
the interface presents a list of user commands or options, such as a
|
| 98 |
+
menu, a prominent item in the list meets this criterion.
|
| 99 |
+
|
| 100 |
+
1. Source Code.
|
| 101 |
+
|
| 102 |
+
The "source code" for a work means the preferred form of the work
|
| 103 |
+
for making modifications to it. "Object code" means any non-source
|
| 104 |
+
form of a work.
|
| 105 |
+
|
| 106 |
+
A "Standard Interface" means an interface that either is an official
|
| 107 |
+
standard defined by a recognized standards body, or, in the case of
|
| 108 |
+
interfaces specified for a particular programming language, one that
|
| 109 |
+
is widely used among developers working in that language.
|
| 110 |
+
|
| 111 |
+
The "System Libraries" of an executable work include anything, other
|
| 112 |
+
than the work as a whole, that (a) is included in the normal form of
|
| 113 |
+
packaging a Major Component, but which is not part of that Major
|
| 114 |
+
Component, and (b) serves only to enable use of the work with that
|
| 115 |
+
Major Component, or to implement a Standard Interface for which an
|
| 116 |
+
implementation is available to the public in source code form. A
|
| 117 |
+
"Major Component", in this context, means a major essential component
|
| 118 |
+
(kernel, window system, and so on) of the specific operating system
|
| 119 |
+
(if any) on which the executable work runs, or a compiler used to
|
| 120 |
+
produce the work, or an object code interpreter used to run it.
|
| 121 |
+
|
| 122 |
+
The "Corresponding Source" for a work in object code form means all
|
| 123 |
+
the source code needed to generate, install, and (for an executable
|
| 124 |
+
work) run the object code and to modify the work, including scripts to
|
| 125 |
+
control those activities. However, it does not include the work's
|
| 126 |
+
System Libraries, or general-purpose tools or generally available free
|
| 127 |
+
programs which are used unmodified in performing those activities but
|
| 128 |
+
which are not part of the work. For example, Corresponding Source
|
| 129 |
+
includes interface definition files associated with source files for
|
| 130 |
+
the work, and the source code for shared libraries and dynamically
|
| 131 |
+
linked subprograms that the work is specifically designed to require,
|
| 132 |
+
such as by intimate data communication or control flow between those
|
| 133 |
+
subprograms and other parts of the work.
|
| 134 |
+
|
| 135 |
+
The Corresponding Source need not include anything that users
|
| 136 |
+
can regenerate automatically from other parts of the Corresponding
|
| 137 |
+
Source.
|
| 138 |
+
|
| 139 |
+
The Corresponding Source for a work in source code form is that
|
| 140 |
+
same work.
|
| 141 |
+
|
| 142 |
+
2. Basic Permissions.
|
| 143 |
+
|
| 144 |
+
All rights granted under this License are granted for the term of
|
| 145 |
+
copyright on the Program, and are irrevocable provided the stated
|
| 146 |
+
conditions are met. This License explicitly affirms your unlimited
|
| 147 |
+
permission to run the unmodified Program. The output from running a
|
| 148 |
+
covered work is covered by this License only if the output, given its
|
| 149 |
+
content, constitutes a covered work. This License acknowledges your
|
| 150 |
+
rights of fair use or other equivalent, as provided by copyright law.
|
| 151 |
+
|
| 152 |
+
You may make, run and propagate covered works that you do not
|
| 153 |
+
convey, without conditions so long as your license otherwise remains
|
| 154 |
+
in force. You may convey covered works to others for the sole purpose
|
| 155 |
+
of having them make modifications exclusively for you, or provide you
|
| 156 |
+
with facilities for running those works, provided that you comply with
|
| 157 |
+
the terms of this License in conveying all material for which you do
|
| 158 |
+
not control copyright. Those thus making or running the covered works
|
| 159 |
+
for you must do so exclusively on your behalf, under your direction
|
| 160 |
+
and control, on terms that prohibit them from making any copies of
|
| 161 |
+
your copyrighted material outside their relationship with you.
|
| 162 |
+
|
| 163 |
+
Conveying under any other circumstances is permitted solely under
|
| 164 |
+
the conditions stated below. Sublicensing is not allowed; section 10
|
| 165 |
+
makes it unnecessary.
|
| 166 |
+
|
| 167 |
+
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
| 168 |
+
|
| 169 |
+
No covered work shall be deemed part of an effective technological
|
| 170 |
+
measure under any applicable law fulfilling obligations under article
|
| 171 |
+
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
| 172 |
+
similar laws prohibiting or restricting circumvention of such
|
| 173 |
+
measures.
|
| 174 |
+
|
| 175 |
+
When you convey a covered work, you waive any legal power to forbid
|
| 176 |
+
circumvention of technological measures to the extent such circumvention
|
| 177 |
+
is effected by exercising rights under this License with respect to
|
| 178 |
+
the covered work, and you disclaim any intention to limit operation or
|
| 179 |
+
modification of the work as a means of enforcing, against the work's
|
| 180 |
+
users, your or third parties' legal rights to forbid circumvention of
|
| 181 |
+
technological measures.
|
| 182 |
+
|
| 183 |
+
4. Conveying Verbatim Copies.
|
| 184 |
+
|
| 185 |
+
You may convey verbatim copies of the Program's source code as you
|
| 186 |
+
receive it, in any medium, provided that you conspicuously and
|
| 187 |
+
appropriately publish on each copy an appropriate copyright notice;
|
| 188 |
+
keep intact all notices stating that this License and any
|
| 189 |
+
non-permissive terms added in accord with section 7 apply to the code;
|
| 190 |
+
keep intact all notices of the absence of any warranty; and give all
|
| 191 |
+
recipients a copy of this License along with the Program.
|
| 192 |
+
|
| 193 |
+
You may charge any price or no price for each copy that you convey,
|
| 194 |
+
and you may offer support or warranty protection for a fee.
|
| 195 |
+
|
| 196 |
+
5. Conveying Modified Source Versions.
|
| 197 |
+
|
| 198 |
+
You may convey a work based on the Program, or the modifications to
|
| 199 |
+
produce it from the Program, in the form of source code under the
|
| 200 |
+
terms of section 4, provided that you also meet all of these conditions:
|
| 201 |
+
|
| 202 |
+
a) The work must carry prominent notices stating that you modified
|
| 203 |
+
it, and giving a relevant date.
|
| 204 |
+
|
| 205 |
+
b) The work must carry prominent notices stating that it is
|
| 206 |
+
released under this License and any conditions added under section
|
| 207 |
+
7. This requirement modifies the requirement in section 4 to
|
| 208 |
+
"keep intact all notices".
|
| 209 |
+
|
| 210 |
+
c) You must license the entire work, as a whole, under this
|
| 211 |
+
License to anyone who comes into possession of a copy. This
|
| 212 |
+
License will therefore apply, along with any applicable section 7
|
| 213 |
+
additional terms, to the whole of the work, and all its parts,
|
| 214 |
+
regardless of how they are packaged. This License gives no
|
| 215 |
+
permission to license the work in any other way, but it does not
|
| 216 |
+
invalidate such permission if you have separately received it.
|
| 217 |
+
|
| 218 |
+
d) If the work has interactive user interfaces, each must display
|
| 219 |
+
Appropriate Legal Notices; however, if the Program has interactive
|
| 220 |
+
interfaces that do not display Appropriate Legal Notices, your
|
| 221 |
+
work need not make them do so.
|
| 222 |
+
|
| 223 |
+
A compilation of a covered work with other separate and independent
|
| 224 |
+
works, which are not by their nature extensions of the covered work,
|
| 225 |
+
and which are not combined with it such as to form a larger program,
|
| 226 |
+
in or on a volume of a storage or distribution medium, is called an
|
| 227 |
+
"aggregate" if the compilation and its resulting copyright are not
|
| 228 |
+
used to limit the access or legal rights of the compilation's users
|
| 229 |
+
beyond what the individual works permit. Inclusion of a covered work
|
| 230 |
+
in an aggregate does not cause this License to apply to the other
|
| 231 |
+
parts of the aggregate.
|
| 232 |
+
|
| 233 |
+
6. Conveying Non-Source Forms.
|
| 234 |
+
|
| 235 |
+
You may convey a covered work in object code form under the terms
|
| 236 |
+
of sections 4 and 5, provided that you also convey the
|
| 237 |
+
machine-readable Corresponding Source under the terms of this License,
|
| 238 |
+
in one of these ways:
|
| 239 |
+
|
| 240 |
+
a) Convey the object code in, or embodied in, a physical product
|
| 241 |
+
(including a physical distribution medium), accompanied by the
|
| 242 |
+
Corresponding Source fixed on a durable physical medium
|
| 243 |
+
customarily used for software interchange.
|
| 244 |
+
|
| 245 |
+
b) Convey the object code in, or embodied in, a physical product
|
| 246 |
+
(including a physical distribution medium), accompanied by a
|
| 247 |
+
written offer, valid for at least three years and valid for as
|
| 248 |
+
long as you offer spare parts or customer support for that product
|
| 249 |
+
model, to give anyone who possesses the object code either (1) a
|
| 250 |
+
copy of the Corresponding Source for all the software in the
|
| 251 |
+
product that is covered by this License, on a durable physical
|
| 252 |
+
medium customarily used for software interchange, for a price no
|
| 253 |
+
more than your reasonable cost of physically performing this
|
| 254 |
+
conveying of source, or (2) access to copy the
|
| 255 |
+
Corresponding Source from a network server at no charge.
|
| 256 |
+
|
| 257 |
+
c) Convey individual copies of the object code with a copy of the
|
| 258 |
+
written offer to provide the Corresponding Source. This
|
| 259 |
+
alternative is allowed only occasionally and noncommercially, and
|
| 260 |
+
only if you received the object code with such an offer, in accord
|
| 261 |
+
with subsection 6b.
|
| 262 |
+
|
| 263 |
+
d) Convey the object code by offering access from a designated
|
| 264 |
+
place (gratis or for a charge), and offer equivalent access to the
|
| 265 |
+
Corresponding Source in the same way through the same place at no
|
| 266 |
+
further charge. You need not require recipients to copy the
|
| 267 |
+
Corresponding Source along with the object code. If the place to
|
| 268 |
+
copy the object code is a network server, the Corresponding Source
|
| 269 |
+
may be on a different server (operated by you or a third party)
|
| 270 |
+
that supports equivalent copying facilities, provided you maintain
|
| 271 |
+
clear directions next to the object code saying where to find the
|
| 272 |
+
Corresponding Source. Regardless of what server hosts the
|
| 273 |
+
Corresponding Source, you remain obligated to ensure that it is
|
| 274 |
+
available for as long as needed to satisfy these requirements.
|
| 275 |
+
|
| 276 |
+
e) Convey the object code using peer-to-peer transmission, provided
|
| 277 |
+
you inform other peers where the object code and Corresponding
|
| 278 |
+
Source of the work are being offered to the general public at no
|
| 279 |
+
charge under subsection 6d.
|
| 280 |
+
|
| 281 |
+
A separable portion of the object code, whose source code is excluded
|
| 282 |
+
from the Corresponding Source as a System Library, need not be
|
| 283 |
+
included in conveying the object code work.
|
| 284 |
+
|
| 285 |
+
A "User Product" is either (1) a "consumer product", which means any
|
| 286 |
+
tangible personal property which is normally used for personal, family,
|
| 287 |
+
or household purposes, or (2) anything designed or sold for incorporation
|
| 288 |
+
into a dwelling. In determining whether a product is a consumer product,
|
| 289 |
+
doubtful cases shall be resolved in favor of coverage. For a particular
|
| 290 |
+
product received by a particular user, "normally used" refers to a
|
| 291 |
+
typical or common use of that class of product, regardless of the status
|
| 292 |
+
of the particular user or of the way in which the particular user
|
| 293 |
+
actually uses, or expects or is expected to use, the product. A product
|
| 294 |
+
is a consumer product regardless of whether the product has substantial
|
| 295 |
+
commercial, industrial or non-consumer uses, unless such uses represent
|
| 296 |
+
the only significant mode of use of the product.
|
| 297 |
+
|
| 298 |
+
"Installation Information" for a User Product means any methods,
|
| 299 |
+
procedures, authorization keys, or other information required to install
|
| 300 |
+
and execute modified versions of a covered work in that User Product from
|
| 301 |
+
a modified version of its Corresponding Source. The information must
|
| 302 |
+
suffice to ensure that the continued functioning of the modified object
|
| 303 |
+
code is in no case prevented or interfered with solely because
|
| 304 |
+
modification has been made.
|
| 305 |
+
|
| 306 |
+
If you convey an object code work under this section in, or with, or
|
| 307 |
+
specifically for use in, a User Product, and the conveying occurs as
|
| 308 |
+
part of a transaction in which the right of possession and use of the
|
| 309 |
+
User Product is transferred to the recipient in perpetuity or for a
|
| 310 |
+
fixed term (regardless of how the transaction is characterized), the
|
| 311 |
+
Corresponding Source conveyed under this section must be accompanied
|
| 312 |
+
by the Installation Information. But this requirement does not apply
|
| 313 |
+
if neither you nor any third party retains the ability to install
|
| 314 |
+
modified object code on the User Product (for example, the work has
|
| 315 |
+
been installed in ROM).
|
| 316 |
+
|
| 317 |
+
The requirement to provide Installation Information does not include a
|
| 318 |
+
requirement to continue to provide support service, warranty, or updates
|
| 319 |
+
for a work that has been modified or installed by the recipient, or for
|
| 320 |
+
the User Product in which it has been modified or installed. Access to a
|
| 321 |
+
network may be denied when the modification itself materially and
|
| 322 |
+
adversely affects the operation of the network or violates the rules and
|
| 323 |
+
protocols for communication across the network.
|
| 324 |
+
|
| 325 |
+
Corresponding Source conveyed, and Installation Information provided,
|
| 326 |
+
in accord with this section must be in a format that is publicly
|
| 327 |
+
documented (and with an implementation available to the public in
|
| 328 |
+
source code form), and must require no special password or key for
|
| 329 |
+
unpacking, reading or copying.
|
| 330 |
+
|
| 331 |
+
7. Additional Terms.
|
| 332 |
+
|
| 333 |
+
"Additional permissions" are terms that supplement the terms of this
|
| 334 |
+
License by making exceptions from one or more of its conditions.
|
| 335 |
+
Additional permissions that are applicable to the entire Program shall
|
| 336 |
+
be treated as though they were included in this License, to the extent
|
| 337 |
+
that they are valid under applicable law. If additional permissions
|
| 338 |
+
apply only to part of the Program, that part may be used separately
|
| 339 |
+
under those permissions, but the entire Program remains governed by
|
| 340 |
+
this License without regard to the additional permissions.
|
| 341 |
+
|
| 342 |
+
When you convey a copy of a covered work, you may at your option
|
| 343 |
+
remove any additional permissions from that copy, or from any part of
|
| 344 |
+
it. (Additional permissions may be written to require their own
|
| 345 |
+
removal in certain cases when you modify the work.) You may place
|
| 346 |
+
additional permissions on material, added by you to a covered work,
|
| 347 |
+
for which you have or can give appropriate copyright permission.
|
| 348 |
+
|
| 349 |
+
Notwithstanding any other provision of this License, for material you
|
| 350 |
+
add to a covered work, you may (if authorized by the copyright holders of
|
| 351 |
+
that material) supplement the terms of this License with terms:
|
| 352 |
+
|
| 353 |
+
a) Disclaiming warranty or limiting liability differently from the
|
| 354 |
+
terms of sections 15 and 16 of this License; or
|
| 355 |
+
|
| 356 |
+
b) Requiring preservation of specified reasonable legal notices or
|
| 357 |
+
author attributions in that material or in the Appropriate Legal
|
| 358 |
+
Notices displayed by works containing it; or
|
| 359 |
+
|
| 360 |
+
c) Prohibiting misrepresentation of the origin of that material, or
|
| 361 |
+
requiring that modified versions of such material be marked in
|
| 362 |
+
reasonable ways as different from the original version; or
|
| 363 |
+
|
| 364 |
+
d) Limiting the use for publicity purposes of names of licensors or
|
| 365 |
+
authors of the material; or
|
| 366 |
+
|
| 367 |
+
e) Declining to grant rights under trademark law for use of some
|
| 368 |
+
trade names, trademarks, or service marks; or
|
| 369 |
+
|
| 370 |
+
f) Requiring indemnification of licensors and authors of that
|
| 371 |
+
material by anyone who conveys the material (or modified versions of
|
| 372 |
+
it) with contractual assumptions of liability to the recipient, for
|
| 373 |
+
any liability that these contractual assumptions directly impose on
|
| 374 |
+
those licensors and authors.
|
| 375 |
+
|
| 376 |
+
All other non-permissive additional terms are considered "further
|
| 377 |
+
restrictions" within the meaning of section 10. If the Program as you
|
| 378 |
+
received it, or any part of it, contains a notice stating that it is
|
| 379 |
+
governed by this License along with a term that is a further
|
| 380 |
+
restriction, you may remove that term. If a license document contains
|
| 381 |
+
a further restriction but permits relicensing or conveying under this
|
| 382 |
+
License, you may add to a covered work material governed by the terms
|
| 383 |
+
of that license document, provided that the further restriction does
|
| 384 |
+
not survive such relicensing or conveying.
|
| 385 |
+
|
| 386 |
+
If you add terms to a covered work in accord with this section, you
|
| 387 |
+
must place, in the relevant source files, a statement of the
|
| 388 |
+
additional terms that apply to those files, or a notice indicating
|
| 389 |
+
where to find the applicable terms.
|
| 390 |
+
|
| 391 |
+
Additional terms, permissive or non-permissive, may be stated in the
|
| 392 |
+
form of a separately written license, or stated as exceptions;
|
| 393 |
+
the above requirements apply either way.
|
| 394 |
+
|
| 395 |
+
8. Termination.
|
| 396 |
+
|
| 397 |
+
You may not propagate or modify a covered work except as expressly
|
| 398 |
+
provided under this License. Any attempt otherwise to propagate or
|
| 399 |
+
modify it is void, and will automatically terminate your rights under
|
| 400 |
+
this License (including any patent licenses granted under the third
|
| 401 |
+
paragraph of section 11).
|
| 402 |
+
|
| 403 |
+
However, if you cease all violation of this License, then your
|
| 404 |
+
license from a particular copyright holder is reinstated (a)
|
| 405 |
+
provisionally, unless and until the copyright holder explicitly and
|
| 406 |
+
finally terminates your license, and (b) permanently, if the copyright
|
| 407 |
+
holder fails to notify you of the violation by some reasonable means
|
| 408 |
+
prior to 60 days after the cessation.
|
| 409 |
+
|
| 410 |
+
Moreover, your license from a particular copyright holder is
|
| 411 |
+
reinstated permanently if the copyright holder notifies you of the
|
| 412 |
+
violation by some reasonable means, this is the first time you have
|
| 413 |
+
received notice of violation of this License (for any work) from that
|
| 414 |
+
copyright holder, and you cure the violation prior to 30 days after
|
| 415 |
+
your receipt of the notice.
|
| 416 |
+
|
| 417 |
+
Termination of your rights under this section does not terminate the
|
| 418 |
+
licenses of parties who have received copies or rights from you under
|
| 419 |
+
this License. If your rights have been terminated and not permanently
|
| 420 |
+
reinstated, you do not qualify to receive new licenses for the same
|
| 421 |
+
material under section 10.
|
| 422 |
+
|
| 423 |
+
9. Acceptance Not Required for Having Copies.
|
| 424 |
+
|
| 425 |
+
You are not required to accept this License in order to receive or
|
| 426 |
+
run a copy of the Program. Ancillary propagation of a covered work
|
| 427 |
+
occurring solely as a consequence of using peer-to-peer transmission
|
| 428 |
+
to receive a copy likewise does not require acceptance. However,
|
| 429 |
+
nothing other than this License grants you permission to propagate or
|
| 430 |
+
modify any covered work. These actions infringe copyright if you do
|
| 431 |
+
not accept this License. Therefore, by modifying or propagating a
|
| 432 |
+
covered work, you indicate your acceptance of this License to do so.
|
| 433 |
+
|
| 434 |
+
10. Automatic Licensing of Downstream Recipients.
|
| 435 |
+
|
| 436 |
+
Each time you convey a covered work, the recipient automatically
|
| 437 |
+
receives a license from the original licensors, to run, modify and
|
| 438 |
+
propagate that work, subject to this License. You are not responsible
|
| 439 |
+
for enforcing compliance by third parties with this License.
|
| 440 |
+
|
| 441 |
+
An "entity transaction" is a transaction transferring control of an
|
| 442 |
+
organization, or substantially all assets of one, or subdividing an
|
| 443 |
+
organization, or merging organizations. If propagation of a covered
|
| 444 |
+
work results from an entity transaction, each party to that
|
| 445 |
+
transaction who receives a copy of the work also receives whatever
|
| 446 |
+
licenses to the work the party's predecessor in interest had or could
|
| 447 |
+
give under the previous paragraph, plus a right to possession of the
|
| 448 |
+
Corresponding Source of the work from the predecessor in interest, if
|
| 449 |
+
the predecessor has it or can get it with reasonable efforts.
|
| 450 |
+
|
| 451 |
+
You may not impose any further restrictions on the exercise of the
|
| 452 |
+
rights granted or affirmed under this License. For example, you may
|
| 453 |
+
not impose a license fee, royalty, or other charge for exercise of
|
| 454 |
+
rights granted under this License, and you may not initiate litigation
|
| 455 |
+
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
| 456 |
+
any patent claim is infringed by making, using, selling, offering for
|
| 457 |
+
sale, or importing the Program or any portion of it.
|
| 458 |
+
|
| 459 |
+
11. Patents.
|
| 460 |
+
|
| 461 |
+
A "contributor" is a copyright holder who authorizes use under this
|
| 462 |
+
License of the Program or a work on which the Program is based. The
|
| 463 |
+
work thus licensed is called the contributor's "contributor version".
|
| 464 |
+
|
| 465 |
+
A contributor's "essential patent claims" are all patent claims
|
| 466 |
+
owned or controlled by the contributor, whether already acquired or
|
| 467 |
+
hereafter acquired, that would be infringed by some manner, permitted
|
| 468 |
+
by this License, of making, using, or selling its contributor version,
|
| 469 |
+
but do not include claims that would be infringed only as a
|
| 470 |
+
consequence of further modification of the contributor version. For
|
| 471 |
+
purposes of this definition, "control" includes the right to grant
|
| 472 |
+
patent sublicenses in a manner consistent with the requirements of
|
| 473 |
+
this License.
|
| 474 |
+
|
| 475 |
+
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
| 476 |
+
patent license under the contributor's essential patent claims, to
|
| 477 |
+
make, use, sell, offer for sale, import and otherwise run, modify and
|
| 478 |
+
propagate the contents of its contributor version.
|
| 479 |
+
|
| 480 |
+
In the following three paragraphs, a "patent license" is any express
|
| 481 |
+
agreement or commitment, however denominated, not to enforce a patent
|
| 482 |
+
(such as an express permission to practice a patent or covenant not to
|
| 483 |
+
sue for patent infringement). To "grant" such a patent license to a
|
| 484 |
+
party means to make such an agreement or commitment not to enforce a
|
| 485 |
+
patent against the party.
|
| 486 |
+
|
| 487 |
+
If you convey a covered work, knowingly relying on a patent license,
|
| 488 |
+
and the Corresponding Source of the work is not available for anyone
|
| 489 |
+
to copy, free of charge and under the terms of this License, through a
|
| 490 |
+
publicly available network server or other readily accessible means,
|
| 491 |
+
then you must either (1) cause the Corresponding Source to be so
|
| 492 |
+
available, or (2) arrange to deprive yourself of the benefit of the
|
| 493 |
+
patent license for this particular work, or (3) arrange, in a manner
|
| 494 |
+
consistent with the requirements of this License, to extend the patent
|
| 495 |
+
license to downstream recipients. "Knowingly relying" means you have
|
| 496 |
+
actual knowledge that, but for the patent license, your conveying the
|
| 497 |
+
covered work in a country, or your recipient's use of the covered work
|
| 498 |
+
in a country, would infringe one or more identifiable patents in that
|
| 499 |
+
country that you have reason to believe are valid.
|
| 500 |
+
|
| 501 |
+
If, pursuant to or in connection with a single transaction or
|
| 502 |
+
arrangement, you convey, or propagate by procuring conveyance of, a
|
| 503 |
+
covered work, and grant a patent license to some of the parties
|
| 504 |
+
receiving the covered work authorizing them to use, propagate, modify
|
| 505 |
+
or convey a specific copy of the covered work, then the patent license
|
| 506 |
+
you grant is automatically extended to all recipients of the covered
|
| 507 |
+
work and works based on it.
|
| 508 |
+
|
| 509 |
+
A patent license is "discriminatory" if it does not include within
|
| 510 |
+
the scope of its coverage, prohibits the exercise of, or is
|
| 511 |
+
conditioned on the non-exercise of one or more of the rights that are
|
| 512 |
+
specifically granted under this License. You may not convey a covered
|
| 513 |
+
work if you are a party to an arrangement with a third party that is
|
| 514 |
+
in the business of distributing software, under which you make payment
|
| 515 |
+
to the third party based on the extent of your activity of conveying
|
| 516 |
+
the work, and under which the third party grants, to any of the
|
| 517 |
+
parties who would receive the covered work from you, a discriminatory
|
| 518 |
+
patent license (a) in connection with copies of the covered work
|
| 519 |
+
conveyed by you (or copies made from those copies), or (b) primarily
|
| 520 |
+
for and in connection with specific products or compilations that
|
| 521 |
+
contain the covered work, unless you entered into that arrangement,
|
| 522 |
+
or that patent license was granted, prior to 28 March 2007.
|
| 523 |
+
|
| 524 |
+
Nothing in this License shall be construed as excluding or limiting
|
| 525 |
+
any implied license or other defenses to infringement that may
|
| 526 |
+
otherwise be available to you under applicable patent law.
|
| 527 |
+
|
| 528 |
+
12. No Surrender of Others' Freedom.
|
| 529 |
+
|
| 530 |
+
If conditions are imposed on you (whether by court order, agreement or
|
| 531 |
+
otherwise) that contradict the conditions of this License, they do not
|
| 532 |
+
excuse you from the conditions of this License. If you cannot convey a
|
| 533 |
+
covered work so as to satisfy simultaneously your obligations under this
|
| 534 |
+
License and any other pertinent obligations, then as a consequence you may
|
| 535 |
+
not convey it at all. For example, if you agree to terms that obligate you
|
| 536 |
+
to collect a royalty for further conveying from those to whom you convey
|
| 537 |
+
the Program, the only way you could satisfy both those terms and this
|
| 538 |
+
License would be to refrain entirely from conveying the Program.
|
| 539 |
+
|
| 540 |
+
13. Remote Network Interaction; Use with the GNU General Public License.
|
| 541 |
+
|
| 542 |
+
Notwithstanding any other provision of this License, if you modify the
|
| 543 |
+
Program, your modified version must prominently offer all users
|
| 544 |
+
interacting with it remotely through a computer network (if your version
|
| 545 |
+
supports such interaction) an opportunity to receive the Corresponding
|
| 546 |
+
Source of your version by providing access to the Corresponding Source
|
| 547 |
+
from a network server at no charge, through some standard or customary
|
| 548 |
+
means of facilitating copying of software. This Corresponding Source
|
| 549 |
+
shall include the Corresponding Source for any work covered by version 3
|
| 550 |
+
of the GNU General Public License that is incorporated pursuant to the
|
| 551 |
+
following paragraph.
|
| 552 |
+
|
| 553 |
+
Notwithstanding any other provision of this License, you have
|
| 554 |
+
permission to link or combine any covered work with a work licensed
|
| 555 |
+
under version 3 of the GNU General Public License into a single
|
| 556 |
+
combined work, and to convey the resulting work. The terms of this
|
| 557 |
+
License will continue to apply to the part which is the covered work,
|
| 558 |
+
but the work with which it is combined will remain governed by version
|
| 559 |
+
3 of the GNU General Public License.
|
| 560 |
+
|
| 561 |
+
14. Revised Versions of this License.
|
| 562 |
+
|
| 563 |
+
The Free Software Foundation may publish revised and/or new versions of
|
| 564 |
+
the GNU Affero General Public License from time to time. Such new versions
|
| 565 |
+
will be similar in spirit to the present version, but may differ in detail to
|
| 566 |
+
address new problems or concerns.
|
| 567 |
+
|
| 568 |
+
Each version is given a distinguishing version number. If the
|
| 569 |
+
Program specifies that a certain numbered version of the GNU Affero General
|
| 570 |
+
Public License "or any later version" applies to it, you have the
|
| 571 |
+
option of following the terms and conditions either of that numbered
|
| 572 |
+
version or of any later version published by the Free Software
|
| 573 |
+
Foundation. If the Program does not specify a version number of the
|
| 574 |
+
GNU Affero General Public License, you may choose any version ever published
|
| 575 |
+
by the Free Software Foundation.
|
| 576 |
+
|
| 577 |
+
If the Program specifies that a proxy can decide which future
|
| 578 |
+
versions of the GNU Affero General Public License can be used, that proxy's
|
| 579 |
+
public statement of acceptance of a version permanently authorizes you
|
| 580 |
+
to choose that version for the Program.
|
| 581 |
+
|
| 582 |
+
Later license versions may give you additional or different
|
| 583 |
+
permissions. However, no additional obligations are imposed on any
|
| 584 |
+
author or copyright holder as a result of your choosing to follow a
|
| 585 |
+
later version.
|
| 586 |
+
|
| 587 |
+
15. Disclaimer of Warranty.
|
| 588 |
+
|
| 589 |
+
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
| 590 |
+
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
| 591 |
+
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
| 592 |
+
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
| 593 |
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
| 594 |
+
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
| 595 |
+
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
| 596 |
+
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
| 597 |
+
|
| 598 |
+
16. Limitation of Liability.
|
| 599 |
+
|
| 600 |
+
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
| 601 |
+
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
| 602 |
+
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
| 603 |
+
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
| 604 |
+
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
| 605 |
+
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
| 606 |
+
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
| 607 |
+
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
| 608 |
+
SUCH DAMAGES.
|
| 609 |
+
|
| 610 |
+
17. Interpretation of Sections 15 and 16.
|
| 611 |
+
|
| 612 |
+
If the disclaimer of warranty and limitation of liability provided
|
| 613 |
+
above cannot be given local legal effect according to their terms,
|
| 614 |
+
reviewing courts shall apply local law that most closely approximates
|
| 615 |
+
an absolute waiver of all civil liability in connection with the
|
| 616 |
+
Program, unless a warranty or assumption of liability accompanies a
|
| 617 |
+
copy of the Program in return for a fee.
|
| 618 |
+
|
| 619 |
+
END OF TERMS AND CONDITIONS
|
| 620 |
+
|
| 621 |
+
How to Apply These Terms to Your New Programs
|
| 622 |
+
|
| 623 |
+
If you develop a new program, and you want it to be of the greatest
|
| 624 |
+
possible use to the public, the best way to achieve this is to make it
|
| 625 |
+
free software which everyone can redistribute and change under these terms.
|
| 626 |
+
|
| 627 |
+
To do so, attach the following notices to the program. It is safest
|
| 628 |
+
to attach them to the start of each source file to most effectively
|
| 629 |
+
state the exclusion of warranty; and each file should have at least
|
| 630 |
+
the "copyright" line and a pointer to where the full notice is found.
|
| 631 |
+
|
| 632 |
+
TG-FileStream – A web server and Telegram client that acts as a proxy between the Telegram servers and an HTTP client to enable file downloads.
|
| 633 |
+
|
| 634 |
+
Copyright (C)2025-2026 DeekshithSH
|
| 635 |
+
|
| 636 |
+
This program is free software: you can redistribute it and/or modify
|
| 637 |
+
it under the terms of the GNU Affero General Public License as published by
|
| 638 |
+
the Free Software Foundation, either version 3 of the License, or
|
| 639 |
+
(at your option) any later version.
|
| 640 |
+
|
| 641 |
+
This program is distributed in the hope that it will be useful,
|
| 642 |
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 643 |
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 644 |
+
GNU Affero General Public License for more details.
|
| 645 |
+
|
| 646 |
+
You should have received a copy of the GNU Affero General Public License
|
| 647 |
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 648 |
+
|
| 649 |
+
Also add information on how to contact you by electronic and paper mail.
|
| 650 |
+
|
| 651 |
+
If your software can interact with users remotely through a computer
|
| 652 |
+
network, you should also make sure that it provides a way for users to
|
| 653 |
+
get its source. For example, if your program is a web application, its
|
| 654 |
+
interface could display a "Source" link that leads users to an archive
|
| 655 |
+
of the code. There are many ways you could offer source, and different
|
| 656 |
+
solutions will be better for different programs; see section 13 for the
|
| 657 |
+
specific requirements.
|
| 658 |
+
|
| 659 |
+
You should also get your employer (if you work as a programmer) or school,
|
| 660 |
+
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
| 661 |
+
For more information on this, and how to apply and follow the GNU AGPL, see
|
| 662 |
+
<http://www.gnu.org/licenses/>.
|
README.md
CHANGED
|
@@ -1,11 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
|
| 3 |
+
> This project is released under the **GNU AGPL v3** license.
|
| 4 |
+
> You are free to use, modify, and distribute it — as long as you share your changes under the same license.
|
| 5 |
+
|
| 6 |
+
**TG-FileStream** is a lightweight web server and Telegram client that acts as a proxy between Telegram servers and HTTP clients, allowing direct downloads of Telegram media files via HTTP.
|
| 7 |
+
|
| 8 |
+
> 📌 Check out [TODO.md](./TODO.md) for the latest development progress and planned features.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 📝 Notes
|
| 13 |
+
|
| 14 |
+
- If you want one without database checkout [DeekshithSH/tgfilestream](https://github.com/DeekshithSH/tgfilestream) or [simple branch](https://github.com/SpringsFern/TG-FileStream/tree/simple)
|
| 15 |
+
|
| 16 |
---
|
| 17 |
+
|
| 18 |
+
## 🚀 Features
|
| 19 |
+
|
| 20 |
+
- Download Telegram media via HTTP links
|
| 21 |
+
- Fast, concurrent chunked downloading
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 🛠️ Setup
|
| 26 |
+
|
| 27 |
+
### 1. Clone the repository
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
git clone https://github.com/SpringsFern/TG-FileStream.git
|
| 31 |
+
cd TG-FileStream
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### 2. Install dependencies
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
pip install -r requirements.txt
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### 3. Create a `.env` file
|
| 41 |
+
|
| 42 |
+
Store the required environment variables in a `.env` file:
|
| 43 |
+
|
| 44 |
+
```env
|
| 45 |
+
API_ID=1234567
|
| 46 |
+
API_HASH=1a2b3c4d5e6f7g8h9i0jklmnopqrstuv
|
| 47 |
+
BOT_TOKEN=1234567890:AAExampleBotTokenGeneratedHere
|
| 48 |
+
BIN_CHANNEL=-1002605638795
|
| 49 |
+
HOST=0.0.0.0
|
| 50 |
+
PORT=8080
|
| 51 |
+
PUBLIC_URL=http://127.0.0.1:8080
|
| 52 |
+
DB_BACKEND=mongodb
|
| 53 |
+
MONGODB_URI=mongodb://admin:pAswaRd@192.168.27.1
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 4. Run the server
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
python3 -m tgfs
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
---
|
| 63 |
|
| 64 |
+
## ⚙️ Environment Variables
|
| 65 |
+
|
| 66 |
+
| Variable | Required/Default | Description |
|
| 67 |
+
| -------------------- | ---------------------- | ---------------------------------------------------------------------------- |
|
| 68 |
+
| `API_ID` | ✅ | App ID from [my.telegram.org](https://my.telegram.org) |
|
| 69 |
+
| `API_HASH` | ✅ | API hash from [my.telegram.org](https://my.telegram.org) |
|
| 70 |
+
| `BOT_TOKEN` | ✅ | Bot token from [@BotFather](https://t.me/BotFather) |
|
| 71 |
+
| `BIN_CHANNEL` | ✅ | Channel ID where files sent to the bot are stored |
|
| 72 |
+
| `DB_BACKEND` | ✅ | Which Database server to use. either `mongodb` or `mysql` |
|
| 73 |
+
| `HOST` | `0.0.0.0` | Host address to bind the server (default: `0.0.0.0`) |
|
| 74 |
+
| `PORT` | `8080` | Port to run the server on (default: `8080`) |
|
| 75 |
+
| `PUBLIC_URL` | `https://0.0.0.0:8080` | Public-facing URL used to generate download links |
|
| 76 |
+
| `DEBUG` | `False` | Show Extra Logs |
|
| 77 |
+
| `CONNECTION_LIMIT` | `5` | Number of connections to create per DC for a single client |
|
| 78 |
+
| `DOWNLOAD_PART_SIZE` | `1048576 (1MB)` | Number of bytes to request in a single chunk |
|
| 79 |
+
| `NO_UPDATE` | `False` | Whether to reply to messages sent to the bot (True to disable replies) |
|
| 80 |
+
| `SEQUENTIAL_UPDATES` | `False` | Handle telegram updates sequentially |
|
| 81 |
+
| `FILE_INDEX_LIMIT` | `10` | Number of files to display at once with `/files` command |
|
| 82 |
+
| `MAX_WARNS` | `3` | Maximum number of warns before user get banned |
|
| 83 |
+
| `ADMIN_IDS` | `None` | User id of users who can use admin commands. Each id is seperated by `,` |
|
| 84 |
+
| `ALLOWED_IDS` | `None` | Only users with these IDs can use the bot. Separate multiple IDs with `,` |
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
### Multi Token Environment Variables
|
| 88 |
+
| Variable | Required/Default | Description |
|
| 89 |
+
| -------------- | ---------------- | ---------------------------------------------------------------------------- |
|
| 90 |
+
| `MULTI_TOKENx` | ✅ | Use Multiple Telegram Clients when downloading files to avoid flood wait, Replace x with Number |
|
| 91 |
+
| | | Example: |
|
| 92 |
+
| `MULTI_TOKEN1` | | MULTI_TOKEN1=1234567890:AAExampleBotTokenGeneratedHere|
|
| 93 |
+
| `MULTI_TOKEN2` | | MULTI_TOKEN2=0987654321:AAExampleBotTokenGeneratedHere|
|
| 94 |
+
| `MULTI_TOKEN3` | | MULTI_TOKEN3=5432167890:AAExampleBotTokenGeneratedHere|
|
| 95 |
+
|
| 96 |
+
### MySQL Environment Variables
|
| 97 |
+
Set the following variables if you choose MySQL as the database in `DB_BACKEND`
|
| 98 |
+
|
| 99 |
+
| Variable | Required/Default | Description |
|
| 100 |
+
| ---------------- | ---------------- | ------------------------------------------ |
|
| 101 |
+
| `MYSQL_HOST` | ✅ | MySQL DataBase Host Name |
|
| 102 |
+
| `MYSQL_PORT` | ✅ | MySQL Database Port Number |
|
| 103 |
+
| `MYSQL_USER` | ✅ | MySQL Database Username |
|
| 104 |
+
| `MYSQL_PASSWORD` | ✅ | MySQL Database Password |
|
| 105 |
+
| `MYSQL_DB` | ✅ | MySQL Database Name |
|
| 106 |
+
| `MYSQL_MINSIZE` | `1` | Minimum sizes of the MySQL Connection pool |
|
| 107 |
+
| `MYSQL_MAXSIZE` | `5` | Maximum sizes of the MySQL Connection pool |
|
| 108 |
+
|
| 109 |
+
### MongoDB Environment Variables
|
| 110 |
+
Set the following variables if you choose MongoDB as the database in `DB_BACKEND`
|
| 111 |
+
|
| 112 |
+
| Variable | Required/Default | Description |
|
| 113 |
+
| ---------------- | ---------------- | --------------------- |
|
| 114 |
+
| `MONGODB_URI` | ✅ | MongoDB Database URI |
|
| 115 |
+
| `MONGODB_DBNAME` | `TGFS` | MongoDB Database name |
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## 📂 Usage
|
| 120 |
+
|
| 121 |
+
Once the server is running, you can:
|
| 122 |
+
|
| 123 |
+
- Access Telegram media files via HTTP:
|
| 124 |
+
|
| 125 |
+
- Or simply send a file to your bot, and it will respond with a download link.
|
| 126 |
+
|
| 127 |
+
This will stream the file directly from Telegram servers to the client.
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## 🛠️ Contributing & Reporting Issues
|
| 132 |
+
|
| 133 |
+
Found a bug or have a feature request? Please [open an issue](https://github.com/SpringsFern/TG-FileStream/issues) on GitHub.
|
| 134 |
+
|
| 135 |
+
### 🐞 Reporting Issues
|
| 136 |
+
When reporting a bug, **please include**:
|
| 137 |
+
- Steps to reproduce the issue
|
| 138 |
+
- Expected behavior vs actual behavior
|
| 139 |
+
- Relevant logs, screenshots, or error messages (if any)
|
| 140 |
+
- Environment details (OS, Python version, etc.)
|
| 141 |
+
|
| 142 |
+
**Example issue title:**
|
| 143 |
+
`[Bug] Download fails for large files`
|
| 144 |
+
|
| 145 |
+
### 💡 Requesting Features
|
| 146 |
+
When suggesting a new feature, **please include**:
|
| 147 |
+
- A clear and concise description of the feature
|
| 148 |
+
- The motivation or use case for it
|
| 149 |
+
- Expected behavior (input/output examples if applicable)
|
| 150 |
+
- Any alternatives you've considered
|
| 151 |
+
|
| 152 |
+
**Example feature title:**
|
| 153 |
+
`[Feature] Add support for viewing generated links`
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
Contributions are welcome!
|
| 158 |
+
Feel free to fork the project and open a pull request.
|
| 159 |
+
|
| 160 |
+
> 🔍 **Note:** Make sure to test your code thoroughly before submitting a PR to help maintain stability and performance.
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 💡 Credits
|
| 165 |
+
|
| 166 |
+
- **Deekshith SH** – Me
|
| 167 |
+
- **Tulir** – Original author of [`tgfilestream`](https://github.com/tulir/tgfilestream), whose code inspired this project and is referenced in `paralleltransfer.py`
|
| 168 |
+
|
| 169 |
+
---
|
TODO.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ✅ TODO
|
| 2 |
+
|
| 3 |
+
| Task | Status |
|
| 4 |
+
|------------------------------------------------------------------------------------------|-------------|
|
| 5 |
+
| Rewrite the entire project based on the Telethon client | ✅ Done |
|
| 6 |
+
| Reuse code from `tgfilestream` (`paralleltransfer.py`) to fetch files from Telegram | ✅ Done |
|
| 7 |
+
| Use multiple Telegram Bot accounts to avoid flood wait | ✅ Done |
|
| 8 |
+
| Add support for multiple databases | ✅ Done |
|
| 9 |
+
| Support for multiple languages | ✅ Done |
|
| 10 |
+
| Share File Info Cache between multiple clients | Not Planned |
|
| 11 |
+
| Prefetch chunks | Not Planned |
|
| 12 |
+
| Cache Files | Not Planned |
|
| 13 |
+
| Add More options in /files command | ⏳ Pending |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 🎯 Goal [⏳ Pending]
|
| 18 |
+
|
| 19 |
+
> **Keep the links generated by the bot as consistent as possible.**
|
| 20 |
+
> Even if the internal code changes, previously generated links should still work.
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
telethon
|
| 2 |
+
aiohttp
|
| 3 |
+
python-dotenv
|
| 4 |
+
cryptg
|
| 5 |
+
aiomysql
|
| 6 |
+
motor
|
| 7 |
+
dnspython
|
tgfs/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.0.1"
|
| 2 |
+
__author__ = "Deekshith SH <deekshith@spri.in>"
|
tgfs/__main__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import traceback
|
| 19 |
+
from aiohttp import web
|
| 20 |
+
from telethon import functions
|
| 21 |
+
from telethon.tl.types import User
|
| 22 |
+
|
| 23 |
+
from tgfs.app import init_app
|
| 24 |
+
from tgfs.info import Version, __version__
|
| 25 |
+
from tgfs.log import log
|
| 26 |
+
from tgfs.config import Config
|
| 27 |
+
from tgfs.paralleltransfer import ParallelTransferrer
|
| 28 |
+
from tgfs.telegram import client, load_plugins, multi_clients, start_clients
|
| 29 |
+
from tgfs.database import DB
|
| 30 |
+
from tgfs.utils.utils import load_configs, load_patches
|
| 31 |
+
|
| 32 |
+
load_patches(Config.PATCH_PATH)
|
| 33 |
+
|
| 34 |
+
app = init_app()
|
| 35 |
+
runner = web.AppRunner(app, handler_cancellation=True)
|
| 36 |
+
|
| 37 |
+
async def additional_check():
|
| 38 |
+
version = await DB.db.get_config_value("VERSION")
|
| 39 |
+
if version != __version__:
|
| 40 |
+
if not version:
|
| 41 |
+
await DB.db.set_config_value("VERSION", __version__)
|
| 42 |
+
return
|
| 43 |
+
_version = version.split(".", maxsplit=3)
|
| 44 |
+
major = int(_version[0])
|
| 45 |
+
minor = int(_version[1])
|
| 46 |
+
patch = int(_version[2])
|
| 47 |
+
if minor != Version.minor or major != Version.major:
|
| 48 |
+
# ToDo: Create Migration Script execute based on version
|
| 49 |
+
log.warning("version mismatch detected. Old version: %s, Current version: %s", version, __version__)
|
| 50 |
+
await DB.db.set_config_value("VERSION", __version__)
|
| 51 |
+
await DB.db.set_config_value("OLD_VERSION", version)
|
| 52 |
+
|
| 53 |
+
async def start() -> None:
|
| 54 |
+
log.info("Initializing Database")
|
| 55 |
+
await DB.init()
|
| 56 |
+
await load_configs()
|
| 57 |
+
log.info("Running Checks")
|
| 58 |
+
await additional_check()
|
| 59 |
+
log.info("Starting Telegram Client")
|
| 60 |
+
await client.start(bot_token=Config.BOT_TOKEN)
|
| 61 |
+
if not Config.NO_UPDATE:
|
| 62 |
+
load_plugins("tgfs/plugins")
|
| 63 |
+
|
| 64 |
+
# https://github.com/LonamiWebs/Telethon/blob/59da66e105ba29eee7760538409181859c7d310d/telethon/client/downloads.py#L62
|
| 65 |
+
config = await client(functions.help.GetConfigRequest())
|
| 66 |
+
for option in config.dc_options:
|
| 67 |
+
if option.ip_address == client.session.server_address:
|
| 68 |
+
client.session.set_dc(
|
| 69 |
+
option.id, option.ip_address, option.port)
|
| 70 |
+
client.session.save()
|
| 71 |
+
break
|
| 72 |
+
me: User = await client.get_me()
|
| 73 |
+
Config.BOT_ID = me.id
|
| 74 |
+
transfer = ParallelTransferrer(client, me.id)
|
| 75 |
+
transfer.post_init()
|
| 76 |
+
multi_clients.append(transfer)
|
| 77 |
+
log.info("Starting Additional Clients")
|
| 78 |
+
await start_clients()
|
| 79 |
+
log.info("Starting HTTP Server")
|
| 80 |
+
await runner.setup()
|
| 81 |
+
await web.TCPSite(runner, Config.HOST, Config.PORT).start()
|
| 82 |
+
log.info("Version: %s", __version__)
|
| 83 |
+
log.info("Username: %s", me.username)
|
| 84 |
+
log.info("DC ID: %d", getattr(client.session, "dc_id", None))
|
| 85 |
+
log.info("URL: %s", Config.PUBLIC_URL)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def stop() -> None:
|
| 89 |
+
log.debug("Stopping HTTP Server")
|
| 90 |
+
await runner.cleanup()
|
| 91 |
+
log.debug("Closing Telegram Client and Connections")
|
| 92 |
+
await client.disconnect()
|
| 93 |
+
log.debug("Closing Database Connection")
|
| 94 |
+
await DB.close()
|
| 95 |
+
log.info("Stopped Bot and Server")
|
| 96 |
+
|
| 97 |
+
def log_pending_tasks(include_stack: bool = False):
|
| 98 |
+
log_child = log.getChild("tasks")
|
| 99 |
+
current = asyncio.current_task()
|
| 100 |
+
tasks = asyncio.all_tasks()
|
| 101 |
+
|
| 102 |
+
log_child.debug("Pending asyncio tasks")
|
| 103 |
+
|
| 104 |
+
for task in tasks:
|
| 105 |
+
if task is current:
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
if task.done():
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
log_child.debug(
|
| 112 |
+
"Task name=%s cancelled=%s coro=%s",
|
| 113 |
+
task.get_name(),
|
| 114 |
+
task.cancelled(),
|
| 115 |
+
task.get_coro(),
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
if include_stack:
|
| 119 |
+
stack = task.get_stack()
|
| 120 |
+
if stack:
|
| 121 |
+
log_child.debug("Stack for task %s:", task.get_name())
|
| 122 |
+
for frame in stack:
|
| 123 |
+
formatted = "".join(traceback.format_stack(frame))
|
| 124 |
+
log_child.debug("%s", formatted)
|
| 125 |
+
else:
|
| 126 |
+
log_child.debug("No stack available for %s", task.get_name())
|
| 127 |
+
|
| 128 |
+
async def main() -> None:
|
| 129 |
+
try:
|
| 130 |
+
await start()
|
| 131 |
+
await client.run_until_disconnected()
|
| 132 |
+
finally:
|
| 133 |
+
log_pending_tasks(True)
|
| 134 |
+
await stop()
|
| 135 |
+
log.info("Stopped Services")
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
try:
|
| 139 |
+
asyncio.run(main())
|
| 140 |
+
except KeyboardInterrupt:
|
| 141 |
+
pass
|
| 142 |
+
except Exception: # pylint: disable=W0718
|
| 143 |
+
log.error(traceback.format_exc())
|
tgfs/app.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiohttp import web
|
| 2 |
+
from tgfs.routes import routes
|
| 3 |
+
|
| 4 |
+
def init_routes(app: web.Application):
|
| 5 |
+
app.add_routes(routes)
|
| 6 |
+
|
| 7 |
+
init_handlers = [init_routes]
|
| 8 |
+
def init_app() -> web.Application:
|
| 9 |
+
app = web.Application()
|
| 10 |
+
|
| 11 |
+
for init in init_handlers:
|
| 12 |
+
init(app)
|
| 13 |
+
return app
|
tgfs/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import logging
|
| 21 |
+
from os import environ
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from tgfs.utils.config_utils import ConfigBase
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from dotenv import load_dotenv
|
| 30 |
+
load_dotenv()
|
| 31 |
+
except ImportError:
|
| 32 |
+
log.warning("python-dotenv not installed. Skipping .env loading.")
|
| 33 |
+
|
| 34 |
+
parser = argparse.ArgumentParser(
|
| 35 |
+
prog="tg-filestream", description="TG-FileStream server",)
|
| 36 |
+
parser.add_argument("--host", help="Bind host")
|
| 37 |
+
parser.add_argument("--port", type=int, help="Bind port")
|
| 38 |
+
parser.add_argument("--public-url", help="Public base URL")
|
| 39 |
+
parser.add_argument("--connection-limit", type=int, help="Max concurrent connections")
|
| 40 |
+
parser.add_argument("--db-backend", help="Database server", choices=("mysql", "mongodb"))
|
| 41 |
+
parser.add_argument("--no-update", action="store_true", help="Ignore Telegram Updates")
|
| 42 |
+
parser.add_argument("--session", help="Name for current instance", default="")
|
| 43 |
+
args = parser.parse_args()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Config(ConfigBase):
|
| 47 |
+
MYSQL_REQUIRED = {"host", "user", "password", "db"}
|
| 48 |
+
MYSQL_CONFIG = {
|
| 49 |
+
"host": (str, None),
|
| 50 |
+
"port": (int, 3306),
|
| 51 |
+
"user": (str, None),
|
| 52 |
+
"password": (str, None),
|
| 53 |
+
"db": (str, None),
|
| 54 |
+
"minsize": (int, 1),
|
| 55 |
+
"maxsize": (int, 5),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
MONGODB_REQUIRED = {"uri"}
|
| 59 |
+
MONGODB_CONFIG = {
|
| 60 |
+
"uri": (str, None),
|
| 61 |
+
"dbname": (str, "TGFS")
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
DB_LIST = {
|
| 65 |
+
"mysql": ("MYSQL", MYSQL_CONFIG, MYSQL_REQUIRED),
|
| 66 |
+
"mongodb": ("MONGODB", MONGODB_CONFIG, MONGODB_REQUIRED)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# ---------- Telegram ----------
|
| 70 |
+
API_ID: int = int(environ["API_ID"])
|
| 71 |
+
API_HASH: str = environ["API_HASH"]
|
| 72 |
+
BOT_TOKEN: str = environ["BOT_TOKEN"]
|
| 73 |
+
|
| 74 |
+
BIN_CHANNEL: int = int(environ["BIN_CHANNEL"])
|
| 75 |
+
TOKENS: list[str] = ConfigBase.get_multi_client_tokens()
|
| 76 |
+
|
| 77 |
+
# ---------- Server ----------
|
| 78 |
+
HOST: str = args.host or environ.get("HOST", "0.0.0.0")
|
| 79 |
+
PORT: int = args.port or ConfigBase.env_int("PORT", 8080)
|
| 80 |
+
PUBLIC_URL: str = args.public_url or environ.get("PUBLIC_URL", f"http://{HOST}:{PORT}")
|
| 81 |
+
|
| 82 |
+
CONNECTION_LIMIT: int = args.connection_limit or ConfigBase.env_int("CONNECTION_LIMIT", 5)
|
| 83 |
+
|
| 84 |
+
DOWNLOAD_PART_SIZE: int = ConfigBase.env_int(
|
| 85 |
+
"DOWNLOAD_PART_SIZE", 1024 * 1024
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# ---------- Bot behavior ----------
|
| 89 |
+
NO_UPDATE: bool = args.no_update or ConfigBase.env_bool("NO_UPDATE")
|
| 90 |
+
SEQUENTIAL_UPDATES: bool = ConfigBase.env_bool("SEQUENTIAL_UPDATES")
|
| 91 |
+
FILE_INDEX_LIMIT: int = ConfigBase.env_int("FILE_INDEX_LIMIT", 10)
|
| 92 |
+
MAX_WARNS: int = ConfigBase.env_int("MAX_WARNS", 3)
|
| 93 |
+
|
| 94 |
+
ADMIN_IDS: set[int] = {
|
| 95 |
+
int(x)
|
| 96 |
+
for x in environ.get("ADMIN_IDS", "").split(",")
|
| 97 |
+
if x.strip().isdigit()
|
| 98 |
+
}
|
| 99 |
+
ALLOWED_IDS: set[int] = {
|
| 100 |
+
int(x)
|
| 101 |
+
for x in environ.get("ALLOWED_IDS", "").split(",")
|
| 102 |
+
if x.strip().isdigit()
|
| 103 |
+
}
|
| 104 |
+
if ALLOWED_IDS:
|
| 105 |
+
ALLOWED_IDS = ALLOWED_IDS | ADMIN_IDS
|
| 106 |
+
|
| 107 |
+
# ---------- DB ----------
|
| 108 |
+
DB_BACKEND: str = args.db_backend or environ.get("DB_BACKEND", "").lower()
|
| 109 |
+
|
| 110 |
+
if DB_BACKEND in DB_LIST:
|
| 111 |
+
DB_CONFIG: dict = ConfigBase.load_backend_config(*DB_LIST[DB_BACKEND])
|
| 112 |
+
else:
|
| 113 |
+
raise RuntimeError(
|
| 114 |
+
f"Unsupported DB_BACKEND '{DB_BACKEND}'. "
|
| 115 |
+
f"Valid options: {DB_LIST.keys()}"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
SESSION_NAME: str = args.session
|
| 119 |
+
|
| 120 |
+
# ---------- Extras ----------
|
| 121 |
+
DEBUG: bool = ConfigBase.env_bool("DEBUG")
|
| 122 |
+
EXT_DEBUG: bool = ConfigBase.env_bool("EXT_DEBUG")
|
| 123 |
+
PATCH_PATH: str = environ.get("PATCH_PATH", "tgfs/patches")
|
| 124 |
+
|
| 125 |
+
# ---------- Security ----------
|
| 126 |
+
SECRET: Optional[bytes] = None
|
| 127 |
+
BOT_ID: Optional[int] = None
|
tgfs/database/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from typing import Optional
|
| 18 |
+
from tgfs.config import Config
|
| 19 |
+
from tgfs.database.mysql import MySQLDB
|
| 20 |
+
from tgfs.database.mongodb import MongoDB
|
| 21 |
+
from tgfs.database.database import BaseStorage
|
| 22 |
+
|
| 23 |
+
_BACKENDS: dict[str, type[BaseStorage]] = {
|
| 24 |
+
"mysql": MySQLDB,
|
| 25 |
+
"mongodb": MongoDB,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
class DB:
|
| 29 |
+
db: Optional[BaseStorage] = None
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
async def init(cls) -> None:
|
| 33 |
+
if cls.db is None:
|
| 34 |
+
backend = Config.DB_BACKEND
|
| 35 |
+
db_cls = _BACKENDS[backend]
|
| 36 |
+
cls.db = db_cls()
|
| 37 |
+
|
| 38 |
+
await cls.db.connect(**Config.DB_CONFIG)
|
| 39 |
+
await cls.db.init_db()
|
| 40 |
+
|
| 41 |
+
@classmethod
|
| 42 |
+
async def close(cls):
|
| 43 |
+
if cls.db:
|
| 44 |
+
await cls.db.close()
|
| 45 |
+
cls.db = None
|
tgfs/database/database.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from abc import ABC, abstractmethod
|
| 18 |
+
from typing import AsyncGenerator, Optional
|
| 19 |
+
|
| 20 |
+
from tgfs.utils.types import SupportedType, FileInfo, FileSource, GroupInfo, InputTypeLocation, User
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BaseStorage(ABC):
|
| 24 |
+
"""
|
| 25 |
+
Abstract base class for storage backends.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
is_connected: bool
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
async def connect(self, **kwargs) -> None:
|
| 32 |
+
"""
|
| 33 |
+
Establish a connection to the backend.
|
| 34 |
+
|
| 35 |
+
Called once during application startup.
|
| 36 |
+
Should raise an exception if the connection fails.
|
| 37 |
+
"""
|
| 38 |
+
raise NotImplementedError
|
| 39 |
+
|
| 40 |
+
@abstractmethod
|
| 41 |
+
async def close(self, force: bool = False) -> None:
|
| 42 |
+
"""
|
| 43 |
+
Close all open connections and release resources.
|
| 44 |
+
|
| 45 |
+
Called during graceful shutdown.
|
| 46 |
+
"""
|
| 47 |
+
raise NotImplementedError
|
| 48 |
+
|
| 49 |
+
@abstractmethod
|
| 50 |
+
async def init_db(self) -> None:
|
| 51 |
+
"""Create tables if they don't exist."""
|
| 52 |
+
raise NotImplementedError
|
| 53 |
+
|
| 54 |
+
@abstractmethod
|
| 55 |
+
async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
|
| 56 |
+
raise NotImplementedError
|
| 57 |
+
|
| 58 |
+
@abstractmethod
|
| 59 |
+
async def update_file_restriction(self, file_id: int, status: bool) -> None:
|
| 60 |
+
raise NotImplementedError
|
| 61 |
+
|
| 62 |
+
@abstractmethod
|
| 63 |
+
async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
|
| 64 |
+
raise NotImplementedError
|
| 65 |
+
|
| 66 |
+
@abstractmethod
|
| 67 |
+
async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
|
| 68 |
+
raise NotImplementedError
|
| 69 |
+
|
| 70 |
+
@abstractmethod
|
| 71 |
+
async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
|
| 72 |
+
raise NotImplementedError
|
| 73 |
+
|
| 74 |
+
@abstractmethod
|
| 75 |
+
async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
|
| 76 |
+
raise NotImplementedError
|
| 77 |
+
|
| 78 |
+
@abstractmethod
|
| 79 |
+
async def get_files(self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 80 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 81 |
+
raise NotImplementedError
|
| 82 |
+
|
| 83 |
+
@abstractmethod
|
| 84 |
+
async def get_files2(self, user_id: int, file_ids: list[int], full: bool = False
|
| 85 |
+
) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
|
| 86 |
+
raise NotImplementedError
|
| 87 |
+
|
| 88 |
+
@abstractmethod
|
| 89 |
+
async def get_file_users(self, file_id: int, ) -> set[int]:
|
| 90 |
+
raise NotImplementedError
|
| 91 |
+
|
| 92 |
+
@abstractmethod
|
| 93 |
+
async def total_files(self, user_id: int) -> int:
|
| 94 |
+
raise NotImplementedError
|
| 95 |
+
|
| 96 |
+
@abstractmethod
|
| 97 |
+
async def delete_file(self, file_id: int) -> bool:
|
| 98 |
+
raise NotImplementedError
|
| 99 |
+
|
| 100 |
+
@abstractmethod
|
| 101 |
+
async def remove_file(self, file_id: int, user_id: int) -> bool:
|
| 102 |
+
raise NotImplementedError
|
| 103 |
+
|
| 104 |
+
@abstractmethod
|
| 105 |
+
async def create_group(self, user_id: int, name: str) -> int:
|
| 106 |
+
raise NotImplementedError
|
| 107 |
+
|
| 108 |
+
@abstractmethod
|
| 109 |
+
async def add_file_to_group(self, group_id: int, user_id: int, file_id: int, order: Optional[int] = None) -> None:
|
| 110 |
+
raise NotImplementedError
|
| 111 |
+
|
| 112 |
+
@abstractmethod
|
| 113 |
+
async def get_groups(self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 114 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 115 |
+
raise NotImplementedError
|
| 116 |
+
|
| 117 |
+
@abstractmethod
|
| 118 |
+
async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
|
| 119 |
+
raise NotImplementedError
|
| 120 |
+
|
| 121 |
+
@abstractmethod
|
| 122 |
+
async def delete_group(self, group_id: int, user_id: int) -> None:
|
| 123 |
+
raise NotImplementedError
|
| 124 |
+
|
| 125 |
+
@abstractmethod
|
| 126 |
+
async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
|
| 127 |
+
raise NotImplementedError
|
| 128 |
+
|
| 129 |
+
@abstractmethod
|
| 130 |
+
async def update_group_order(self, group_id: int, file_id: int, user_id: int, new_order: int) -> None:
|
| 131 |
+
raise NotImplementedError
|
| 132 |
+
|
| 133 |
+
@abstractmethod
|
| 134 |
+
async def total_groups(self, user_id: int) -> int:
|
| 135 |
+
raise NotImplementedError
|
| 136 |
+
|
| 137 |
+
@abstractmethod
|
| 138 |
+
async def get_user(self, user_id: int) -> Optional[User]:
|
| 139 |
+
raise NotImplementedError
|
| 140 |
+
|
| 141 |
+
@abstractmethod
|
| 142 |
+
async def add_user(self, user_id: int) -> bool:
|
| 143 |
+
raise NotImplementedError
|
| 144 |
+
|
| 145 |
+
@abstractmethod
|
| 146 |
+
async def upsert_user(self, user: User) -> bool:
|
| 147 |
+
raise NotImplementedError
|
| 148 |
+
|
| 149 |
+
@abstractmethod
|
| 150 |
+
async def delete_user(self, user_id: int) -> bool:
|
| 151 |
+
raise NotImplementedError
|
| 152 |
+
|
| 153 |
+
@abstractmethod
|
| 154 |
+
async def get_users(self) -> AsyncGenerator[User, None]:
|
| 155 |
+
raise NotImplementedError
|
| 156 |
+
|
| 157 |
+
@abstractmethod
|
| 158 |
+
async def count_users(self) -> int:
|
| 159 |
+
raise NotImplementedError
|
| 160 |
+
|
| 161 |
+
@abstractmethod
|
| 162 |
+
async def get_secret(self, rotate=False) -> bytes:
|
| 163 |
+
raise NotImplementedError
|
| 164 |
+
|
| 165 |
+
@abstractmethod
|
| 166 |
+
async def get_config_value(self, key: str) -> Optional[SupportedType]:
|
| 167 |
+
raise NotImplementedError
|
| 168 |
+
|
| 169 |
+
@abstractmethod
|
| 170 |
+
async def set_config_value(self, key: str, value: SupportedType) -> None:
|
| 171 |
+
raise NotImplementedError
|
tgfs/database/mongodb/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection
|
| 18 |
+
|
| 19 |
+
from tgfs.database.database import BaseStorage
|
| 20 |
+
|
| 21 |
+
from .file import FileDB
|
| 22 |
+
from .group import GroupDB
|
| 23 |
+
from .user import UserDB
|
| 24 |
+
from .utils import UtilDB
|
| 25 |
+
|
| 26 |
+
class MongoDB(FileDB, GroupDB, UserDB, UtilDB, BaseStorage):
|
| 27 |
+
is_connected: bool = False
|
| 28 |
+
client: AsyncIOMotorClient
|
| 29 |
+
db: AsyncIOMotorDatabase
|
| 30 |
+
files: AsyncIOMotorCollection
|
| 31 |
+
groups: AsyncIOMotorCollection
|
| 32 |
+
users: AsyncIOMotorCollection
|
| 33 |
+
config: AsyncIOMotorCollection
|
| 34 |
+
|
| 35 |
+
async def connect(self, uri: str, dbname) -> None: # pylint: disable=W0221
|
| 36 |
+
if not self.is_connected:
|
| 37 |
+
self.client = AsyncIOMotorClient(uri)
|
| 38 |
+
self.is_connected = True
|
| 39 |
+
self.db = self.client[dbname]
|
| 40 |
+
|
| 41 |
+
self.files = self.db.files
|
| 42 |
+
self.groups = self.db.groups
|
| 43 |
+
self.users = self.db.users
|
| 44 |
+
self.config = self.db.app_config
|
| 45 |
+
|
| 46 |
+
async def close(self, force: bool = False) -> None:
|
| 47 |
+
if self.is_connected or force:
|
| 48 |
+
self.client.close()
|
| 49 |
+
self.is_connected = False
|
| 50 |
+
|
| 51 |
+
async def init_db(self) -> None:
|
| 52 |
+
await self._create_indexes()
|
| 53 |
+
|
| 54 |
+
async def _create_indexes(self) -> None:
|
| 55 |
+
await self.files.create_index("users.user_id")
|
| 56 |
+
await self.files.create_index("is_deleted")
|
| 57 |
+
await self.files.create_index("users.added_at")
|
| 58 |
+
|
| 59 |
+
await self.groups.create_index("user_id")
|
| 60 |
+
await self.groups.create_index([("user_id", 1), ("created_at", -1)])
|
| 61 |
+
|
| 62 |
+
await self.users.create_index("ban_date")
|
| 63 |
+
await self.users.create_index("warns")
|
tgfs/database/mongodb/file.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from typing import AsyncGenerator, Optional, Union
|
| 19 |
+
|
| 20 |
+
from bson import ObjectId
|
| 21 |
+
from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase
|
| 22 |
+
from telethon.tl.types import InputDocumentFileLocation, InputPhotoFileLocation
|
| 23 |
+
|
| 24 |
+
from tgfs.database.database import BaseStorage
|
| 25 |
+
from tgfs.utils.types import FileInfo, FileSource, InputTypeLocation
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FileDB(BaseStorage):
|
| 29 |
+
db: AsyncIOMotorDatabase
|
| 30 |
+
files: AsyncIOMotorCollection
|
| 31 |
+
groups: AsyncIOMotorCollection
|
| 32 |
+
|
| 33 |
+
async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
|
| 34 |
+
await self.files.update_one(
|
| 35 |
+
{"_id": file.id},
|
| 36 |
+
{
|
| 37 |
+
"$set": {
|
| 38 |
+
"dc_id": file.dc_id,
|
| 39 |
+
"size": file.file_size,
|
| 40 |
+
"mime_type": file.mime_type,
|
| 41 |
+
"file_name": file.file_name,
|
| 42 |
+
"thumb_size": file.thumb_size,
|
| 43 |
+
"is_deleted": file.is_deleted,
|
| 44 |
+
f"users.{user_id}": {
|
| 45 |
+
"chat_id": source.chat_id,
|
| 46 |
+
"message_id": source.message_id,
|
| 47 |
+
"added_at": datetime.now(timezone.utc),
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
},
|
| 51 |
+
upsert=True,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
async def update_file_restriction(self, file_id: int, status: bool) -> None:
|
| 55 |
+
await self.files.update_one(
|
| 56 |
+
{"_id": file_id},
|
| 57 |
+
{"$set": {"is_deleted": status}},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
|
| 61 |
+
query = {"_id": file_id}
|
| 62 |
+
if user_id is not None:
|
| 63 |
+
query[f"users.{user_id}"] = {"$exists": True}
|
| 64 |
+
|
| 65 |
+
doc = await self.files.find_one(query)
|
| 66 |
+
if not doc:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
return FileInfo(
|
| 70 |
+
id=doc["_id"],
|
| 71 |
+
dc_id=doc["dc_id"],
|
| 72 |
+
file_size=doc["size"],
|
| 73 |
+
mime_type=doc.get("mime_type"),
|
| 74 |
+
file_name=doc.get("file_name"),
|
| 75 |
+
thumb_size=doc.get("thumb_size"),
|
| 76 |
+
is_deleted=bool(doc.get("is_deleted", False)),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
|
| 80 |
+
doc = await self.files.find_one(
|
| 81 |
+
{"_id": file.id, f"location.{bot_id}": {"$exists": True}},
|
| 82 |
+
{f"location.{bot_id}": 1},
|
| 83 |
+
)
|
| 84 |
+
if not doc:
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
loc = doc["location"][str(bot_id)]
|
| 88 |
+
cls = InputPhotoFileLocation if file.thumb_size else InputDocumentFileLocation
|
| 89 |
+
return cls(
|
| 90 |
+
id=file.id,
|
| 91 |
+
access_hash=loc["access_hash"],
|
| 92 |
+
file_reference=loc["file_reference"],
|
| 93 |
+
thumb_size=file.thumb_size,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
|
| 97 |
+
doc = await self.files.find_one(
|
| 98 |
+
{"_id": file_id, f"users.{user_id}": {"$exists": True}},
|
| 99 |
+
{f"users.{user_id}": 1},
|
| 100 |
+
)
|
| 101 |
+
if not doc:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
u = doc["users"][str(user_id)]
|
| 105 |
+
return FileSource(
|
| 106 |
+
chat_id=u["chat_id"],
|
| 107 |
+
message_id=u["message_id"],
|
| 108 |
+
time=u["added_at"],
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
|
| 112 |
+
await self.files.update_one(
|
| 113 |
+
{"_id": loc.id},
|
| 114 |
+
{
|
| 115 |
+
"$set": {
|
| 116 |
+
f"location.{bot_id}": {
|
| 117 |
+
"access_hash": loc.access_hash,
|
| 118 |
+
"file_reference": loc.file_reference,
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
async def get_files(
|
| 125 |
+
self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 126 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 127 |
+
cursor = self.files.find(
|
| 128 |
+
{f"users.{user_id}": {"$exists": True}},
|
| 129 |
+
{"file_name": 1, f"users.{user_id}.added_at": 1},
|
| 130 |
+
).sort(f"users.{user_id}.added_at", -1)
|
| 131 |
+
|
| 132 |
+
if offset:
|
| 133 |
+
cursor = cursor.skip(offset)
|
| 134 |
+
if limit is not None:
|
| 135 |
+
cursor = cursor.limit(limit)
|
| 136 |
+
|
| 137 |
+
async for doc in cursor:
|
| 138 |
+
yield doc["_id"], doc.get("file_name")
|
| 139 |
+
|
| 140 |
+
async def get_files2(
|
| 141 |
+
self, user_id: int, file_ids: list[int], full: bool = False,
|
| 142 |
+
) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
|
| 143 |
+
|
| 144 |
+
if not file_ids:
|
| 145 |
+
return
|
| 146 |
+
|
| 147 |
+
projection = None
|
| 148 |
+
if not full:
|
| 149 |
+
projection = {
|
| 150 |
+
"file_name": 1,
|
| 151 |
+
f"users.{user_id}.added_at": 1,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
cursor = self.files.find(
|
| 155 |
+
{
|
| 156 |
+
"_id": {"$in": file_ids},
|
| 157 |
+
f"users.{user_id}": {"$exists": True},
|
| 158 |
+
},
|
| 159 |
+
projection,
|
| 160 |
+
).sort(f"users.{user_id}.added_at", -1)
|
| 161 |
+
|
| 162 |
+
if full:
|
| 163 |
+
async for doc in cursor:
|
| 164 |
+
yield FileInfo(
|
| 165 |
+
id=doc["_id"],
|
| 166 |
+
dc_id=doc["dc_id"],
|
| 167 |
+
file_size=doc["size"],
|
| 168 |
+
mime_type=doc.get("mime_type"),
|
| 169 |
+
file_name=doc.get("file_name"),
|
| 170 |
+
thumb_size=doc.get("thumb_size"),
|
| 171 |
+
is_deleted=bool(doc.get("is_deleted", False)),
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
async for doc in cursor:
|
| 175 |
+
yield doc["_id"], doc.get("file_name")
|
| 176 |
+
|
| 177 |
+
async def get_file_users(self, file_id: int) -> set[int]:
|
| 178 |
+
doc = await self.files.find_one(
|
| 179 |
+
{"_id": file_id},
|
| 180 |
+
{"users": 1},
|
| 181 |
+
)
|
| 182 |
+
if not doc or "users" not in doc:
|
| 183 |
+
return set()
|
| 184 |
+
|
| 185 |
+
return {int(uid) for uid in doc["users"].keys()}
|
| 186 |
+
|
| 187 |
+
async def total_files(self, user_id: int) -> int:
|
| 188 |
+
return await self.files.count_documents(
|
| 189 |
+
{f"users.{user_id}": {"$exists": True}}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
async def delete_file(self, file_id: int) -> bool:
|
| 193 |
+
res = await self.files.delete_one({"_id": file_id})
|
| 194 |
+
return res.deleted_count > 0
|
| 195 |
+
|
| 196 |
+
async def _delete_file_from_group(self, user_id: int, file_id: int,) -> None:
|
| 197 |
+
await self.groups.update_one(
|
| 198 |
+
{
|
| 199 |
+
"user_id": user_id,
|
| 200 |
+
f"files.{file_id}": {"$exists": True},
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"$unset": {
|
| 204 |
+
f"files.{file_id}": ""
|
| 205 |
+
}
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
async def remove_file(self, file_id: int, user_id: int) -> bool:
|
| 210 |
+
res = await self.files.update_one(
|
| 211 |
+
{"_id": file_id},
|
| 212 |
+
{"$unset": {f"users.{user_id}": ""}},
|
| 213 |
+
)
|
| 214 |
+
await self._delete_file_from_group(user_id, file_id)
|
| 215 |
+
return res.modified_count > 0
|
| 216 |
+
|
| 217 |
+
async def get_file_old(self, file_id: str, user_id: int = None) -> Optional[dict[str, Union[ObjectId, int]]]:
|
| 218 |
+
query = {"_id": ObjectId(file_id)}
|
| 219 |
+
if user_id is not None:
|
| 220 |
+
query["users_id"] = user_id
|
| 221 |
+
|
| 222 |
+
doc = await self.db.maplinks.find_one(query)
|
| 223 |
+
if not doc:
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
return doc
|
tgfs/database/mongodb/group.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from typing import AsyncGenerator, Optional
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
|
| 20 |
+
from pymongo import ReturnDocument
|
| 21 |
+
from motor.motor_asyncio import AsyncIOMotorCollection
|
| 22 |
+
|
| 23 |
+
from tgfs.database.database import BaseStorage
|
| 24 |
+
from tgfs.utils.types import GroupInfo
|
| 25 |
+
|
| 26 |
+
class GroupDB(BaseStorage):
|
| 27 |
+
groups: AsyncIOMotorCollection
|
| 28 |
+
config: AsyncIOMotorCollection
|
| 29 |
+
|
| 30 |
+
async def group_counter(self) -> int:
|
| 31 |
+
"""
|
| 32 |
+
Generate the next sequential group ID using atomic MongoDB counter pattern.
|
| 33 |
+
|
| 34 |
+
Uses atomic find_one_and_update operation to increment a counter document
|
| 35 |
+
in the config collection. If the counter doesn't exist, it's created with
|
| 36 |
+
initial value 1 (upsert=True).
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
int: Next group ID (incrementing sequence starting from 1)
|
| 40 |
+
"""
|
| 41 |
+
result = await self.config.find_one_and_update(
|
| 42 |
+
{"_id": "group.counter"},
|
| 43 |
+
{"$inc": {"value": 1}},
|
| 44 |
+
upsert=True,
|
| 45 |
+
return_document=ReturnDocument.AFTER,
|
| 46 |
+
)
|
| 47 |
+
return result["value"]
|
| 48 |
+
|
| 49 |
+
async def create_group(self, user_id: int, name: str) -> int:
|
| 50 |
+
group_id = await self.group_counter()
|
| 51 |
+
await self.groups.insert_one({
|
| 52 |
+
"_id": group_id,
|
| 53 |
+
"user_id": user_id,
|
| 54 |
+
"name": name,
|
| 55 |
+
"created_at": datetime.now(timezone.utc),
|
| 56 |
+
"files": {},
|
| 57 |
+
})
|
| 58 |
+
return group_id
|
| 59 |
+
|
| 60 |
+
async def add_file_to_group(
|
| 61 |
+
self,
|
| 62 |
+
group_id: int,
|
| 63 |
+
user_id: int,
|
| 64 |
+
file_id: int,
|
| 65 |
+
order: Optional[int] = None,
|
| 66 |
+
) -> None:
|
| 67 |
+
if order is None:
|
| 68 |
+
doc = await self.groups.find_one(
|
| 69 |
+
{"_id": group_id, "user_id": user_id},
|
| 70 |
+
{"files": 1},
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
order = 1
|
| 74 |
+
if doc and "files" in doc:
|
| 75 |
+
order = len(doc["files"])+1
|
| 76 |
+
|
| 77 |
+
await self.groups.update_one(
|
| 78 |
+
{"_id": group_id, "user_id": user_id},
|
| 79 |
+
{
|
| 80 |
+
"$set": {
|
| 81 |
+
f"files.{file_id}": {"order": order}
|
| 82 |
+
}
|
| 83 |
+
},
|
| 84 |
+
upsert=False,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
async def get_groups(
|
| 88 |
+
self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 89 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 90 |
+
|
| 91 |
+
cursor = (
|
| 92 |
+
self.groups.find(
|
| 93 |
+
{"user_id": user_id},
|
| 94 |
+
{"name": 1},
|
| 95 |
+
)
|
| 96 |
+
.sort("created_at", -1)
|
| 97 |
+
.skip(offset)
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
if limit is not None:
|
| 101 |
+
cursor = cursor.limit(limit)
|
| 102 |
+
|
| 103 |
+
async for doc in cursor:
|
| 104 |
+
yield doc["_id"], doc["name"]
|
| 105 |
+
|
| 106 |
+
async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
|
| 107 |
+
doc = await self.groups.find_one(
|
| 108 |
+
{"_id": group_id, "user_id": user_id}
|
| 109 |
+
)
|
| 110 |
+
if not doc:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
files = [
|
| 114 |
+
int(fid)
|
| 115 |
+
for fid, _ in sorted(
|
| 116 |
+
doc.get("files", {}).items(),
|
| 117 |
+
key=lambda i: i[1]["order"]
|
| 118 |
+
)
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
return GroupInfo(
|
| 122 |
+
group_id=doc["_id"],
|
| 123 |
+
user_id=doc["user_id"],
|
| 124 |
+
name=doc["name"],
|
| 125 |
+
created_at=doc["created_at"],
|
| 126 |
+
files=files
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
async def delete_group(self, group_id: int, user_id: int) -> None:
|
| 130 |
+
await self.groups.delete_one(
|
| 131 |
+
{"_id": group_id, "user_id": user_id}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
|
| 135 |
+
await self.groups.update_one(
|
| 136 |
+
{"_id": group_id, "user_id": user_id},
|
| 137 |
+
{"$set": {"name": name}},
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
async def update_group_order(
|
| 141 |
+
self, group_id: int, file_id: int, user_id: int, new_order: int
|
| 142 |
+
) -> None:
|
| 143 |
+
await self.groups.update_one(
|
| 144 |
+
{
|
| 145 |
+
"_id": group_id,
|
| 146 |
+
"user_id": user_id,
|
| 147 |
+
f"files.{file_id}": {"$exists": True},
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"$set": {
|
| 151 |
+
f"files.{file_id}.order": new_order
|
| 152 |
+
}
|
| 153 |
+
},
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
async def total_groups(self, user_id: int) -> int:
|
| 157 |
+
return await self.groups.count_documents(
|
| 158 |
+
{"user_id": user_id}
|
| 159 |
+
)
|
tgfs/database/mongodb/user.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from typing import Optional, AsyncGenerator
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
|
| 20 |
+
from motor.motor_asyncio import AsyncIOMotorCollection
|
| 21 |
+
|
| 22 |
+
from tgfs.database.database import BaseStorage
|
| 23 |
+
from tgfs.utils.types import Status, User
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UserDB(BaseStorage):
|
| 27 |
+
users: AsyncIOMotorCollection
|
| 28 |
+
|
| 29 |
+
async def get_user(self, user_id: int) -> Optional[User]:
|
| 30 |
+
doc = await self.users.find_one({"_id": user_id})
|
| 31 |
+
if not doc:
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
return User(
|
| 35 |
+
user_id=doc["_id"],
|
| 36 |
+
join_date=doc["join_date"],
|
| 37 |
+
ban_date=doc.get("ban_date"),
|
| 38 |
+
warns=doc["warns"],
|
| 39 |
+
preferred_lang=doc["preferred_lang"],
|
| 40 |
+
curt_op=Status(doc["curt_op"]),
|
| 41 |
+
op_id=doc["op_id"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
async def add_user(self, user_id: int) -> bool:
|
| 45 |
+
try:
|
| 46 |
+
await self.users.insert_one({
|
| 47 |
+
"_id": user_id,
|
| 48 |
+
"join_date": datetime.now(timezone.utc),
|
| 49 |
+
"ban_date": None,
|
| 50 |
+
"warns": 0,
|
| 51 |
+
"preferred_lang": "en",
|
| 52 |
+
"curt_op": 0,
|
| 53 |
+
"op_id": 0,
|
| 54 |
+
})
|
| 55 |
+
return True
|
| 56 |
+
except Exception: # pylint: disable=W0718
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
async def upsert_user(self, user: User) -> bool:
|
| 60 |
+
await self.users.update_one(
|
| 61 |
+
{"_id": user.user_id},
|
| 62 |
+
{
|
| 63 |
+
"$set": {
|
| 64 |
+
"join_date": user.join_date,
|
| 65 |
+
"ban_date": user.ban_date,
|
| 66 |
+
"warns": user.warns,
|
| 67 |
+
"preferred_lang": user.preferred_lang,
|
| 68 |
+
"curt_op": user.curt_op.value,
|
| 69 |
+
"op_id": user.op_id,
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
upsert=True,
|
| 73 |
+
)
|
| 74 |
+
return True
|
| 75 |
+
|
| 76 |
+
async def delete_user(self, user_id: int) -> bool:
|
| 77 |
+
res = await self.users.delete_one({"_id": user_id})
|
| 78 |
+
return res.deleted_count > 0
|
| 79 |
+
|
| 80 |
+
async def get_users(self) -> AsyncGenerator[User, None]:
|
| 81 |
+
cursor = self.users.find({})
|
| 82 |
+
|
| 83 |
+
async for doc in cursor:
|
| 84 |
+
yield User(
|
| 85 |
+
user_id=doc["_id"],
|
| 86 |
+
join_date=doc["join_date"],
|
| 87 |
+
ban_date=doc.get("ban_date"),
|
| 88 |
+
warns=doc["warns"],
|
| 89 |
+
preferred_lang=doc["preferred_lang"],
|
| 90 |
+
curt_op=doc["curt_op"],
|
| 91 |
+
op_id=doc["op_id"],
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
async def count_users(self) -> int:
|
| 95 |
+
return await self.users.count_documents({})
|
tgfs/database/mongodb/utils.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
from motor.motor_asyncio import AsyncIOMotorCollection
|
| 20 |
+
from bson.binary import Binary
|
| 21 |
+
|
| 22 |
+
from tgfs.database.database import BaseStorage
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class UtilDB(BaseStorage):
|
| 26 |
+
config: AsyncIOMotorCollection
|
| 27 |
+
async def get_secret(self, rotate: bool = False) -> bytes:
|
| 28 |
+
if not rotate:
|
| 29 |
+
doc = await self.config.find_one({"_id": "link.secret"})
|
| 30 |
+
if doc:
|
| 31 |
+
return doc["value"]
|
| 32 |
+
|
| 33 |
+
secret = os.urandom(32)
|
| 34 |
+
|
| 35 |
+
await self.config.update_one(
|
| 36 |
+
{"_id": "link.secret"},
|
| 37 |
+
{"$set": {"value": Binary(secret)}},
|
| 38 |
+
upsert=True,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return secret
|
| 42 |
+
|
| 43 |
+
async def get_config_value(self, key: str):
|
| 44 |
+
doc = await self.config.find_one({"_id": key})
|
| 45 |
+
return doc["value"] if doc else None
|
| 46 |
+
|
| 47 |
+
async def set_config_value(self, key: str, value):
|
| 48 |
+
await self.config.update_one(
|
| 49 |
+
{"_id": key},
|
| 50 |
+
{"$set": {"value": value}},
|
| 51 |
+
upsert=True,
|
| 52 |
+
)
|
tgfs/database/mysql/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import warnings
|
| 19 |
+
|
| 20 |
+
import aiomysql
|
| 21 |
+
|
| 22 |
+
from .file import FileDB
|
| 23 |
+
from .user import UserDB
|
| 24 |
+
from .group import GroupDB
|
| 25 |
+
from .utils import UtilDB
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def read_sql_file(path: str) -> list[str]:
|
| 29 |
+
sql = Path(path).read_text(encoding="utf-8")
|
| 30 |
+
statements = []
|
| 31 |
+
buffer = []
|
| 32 |
+
|
| 33 |
+
for line in sql.splitlines():
|
| 34 |
+
line = line.strip()
|
| 35 |
+
if not line or line.startswith("--"):
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
buffer.append(line)
|
| 39 |
+
if line.endswith(";"):
|
| 40 |
+
statements.append(" ".join(buffer))
|
| 41 |
+
buffer.clear()
|
| 42 |
+
|
| 43 |
+
return statements
|
| 44 |
+
|
| 45 |
+
class MySQLDB(FileDB, GroupDB, UserDB, UtilDB):
|
| 46 |
+
_pool: aiomysql.Pool
|
| 47 |
+
is_connected: bool = False
|
| 48 |
+
|
| 49 |
+
async def connect(self, *, host: str, port: int = 3306, user: str, password: str, # pylint: disable=W0221
|
| 50 |
+
db: str, minsize: int = 1, maxsize: int = 10, autocommit: bool = False,
|
| 51 |
+
connect_timeout: int = 10) -> None:
|
| 52 |
+
if not self.is_connected:
|
| 53 |
+
self._pool = await aiomysql.create_pool(
|
| 54 |
+
host=host, port=port, user=user, password=password, db=db,
|
| 55 |
+
minsize=minsize, maxsize=maxsize, autocommit=autocommit,
|
| 56 |
+
connect_timeout=connect_timeout, charset="utf8mb4"
|
| 57 |
+
)
|
| 58 |
+
self.is_connected = True
|
| 59 |
+
|
| 60 |
+
async def close(self, force: bool = False) -> None:
|
| 61 |
+
if self.is_connected or force:
|
| 62 |
+
self._pool.close()
|
| 63 |
+
await self._pool.wait_closed()
|
| 64 |
+
self.is_connected = False
|
| 65 |
+
|
| 66 |
+
async def init_db(self) -> None:
|
| 67 |
+
statements = read_sql_file("tgfs/database/mysql/schema.sql")
|
| 68 |
+
|
| 69 |
+
async with self._pool.acquire() as conn:
|
| 70 |
+
warnings.filterwarnings('ignore', module=r"aiomysql")
|
| 71 |
+
async with conn.cursor() as cur:
|
| 72 |
+
for stmt in statements:
|
| 73 |
+
await cur.execute(stmt)
|
| 74 |
+
await conn.commit()
|
| 75 |
+
warnings.filterwarnings('default', module=r"aiomysql")
|
tgfs/database/mysql/file.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from typing import AsyncGenerator, Optional
|
| 18 |
+
|
| 19 |
+
import aiomysql
|
| 20 |
+
from telethon.tl.types import InputDocumentFileLocation, InputPhotoFileLocation
|
| 21 |
+
|
| 22 |
+
from tgfs.database.database import BaseStorage
|
| 23 |
+
from tgfs.utils.types import FileSource, FileInfo, InputTypeLocation
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class FileDB(BaseStorage):
|
| 27 |
+
_pool: aiomysql.Pool
|
| 28 |
+
|
| 29 |
+
async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
|
| 30 |
+
async with self._pool.acquire() as conn:
|
| 31 |
+
async with conn.cursor() as cur:
|
| 32 |
+
try:
|
| 33 |
+
await cur.execute(
|
| 34 |
+
"""
|
| 35 |
+
INSERT INTO TGFILE (id, dc_id, size, mime_type, file_name, thumb_size, is_deleted)
|
| 36 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
| 37 |
+
ON DUPLICATE KEY UPDATE
|
| 38 |
+
dc_id = VALUES(dc_id),
|
| 39 |
+
size = VALUES(size),
|
| 40 |
+
mime_type = VALUES(mime_type),
|
| 41 |
+
file_name = VALUES(file_name),
|
| 42 |
+
thumb_size = VALUES(thumb_size),
|
| 43 |
+
is_deleted = VALUES(is_deleted)
|
| 44 |
+
""",
|
| 45 |
+
(
|
| 46 |
+
file.id, file.dc_id, file.file_size, file.mime_type,
|
| 47 |
+
file.file_name, file.thumb_size,
|
| 48 |
+
file.is_deleted
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
await cur.execute(
|
| 52 |
+
"""
|
| 53 |
+
INSERT INTO USER_FILE (user_id, id, source_chat_id, source_msg_id)
|
| 54 |
+
VALUES (%s, %s, %s, %s)
|
| 55 |
+
ON DUPLICATE KEY UPDATE
|
| 56 |
+
source_chat_id = COALESCE(VALUES(source_chat_id), source_chat_id),
|
| 57 |
+
source_msg_id = COALESCE(VALUES(source_msg_id), source_msg_id)
|
| 58 |
+
""",
|
| 59 |
+
(user_id, file.id, source.chat_id, source.message_id)
|
| 60 |
+
)
|
| 61 |
+
await conn.commit()
|
| 62 |
+
except Exception:
|
| 63 |
+
await conn.rollback()
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
async def update_file_restriction(self, file_id: int, status: bool) -> None:
|
| 67 |
+
async with self._pool.acquire() as conn:
|
| 68 |
+
async with conn.cursor() as cur:
|
| 69 |
+
try:
|
| 70 |
+
await cur.execute(
|
| 71 |
+
"""
|
| 72 |
+
UPDATE TGFILE SET is_deleted = %s WHERE id = %s
|
| 73 |
+
""",
|
| 74 |
+
(status, file_id)
|
| 75 |
+
)
|
| 76 |
+
await conn.commit()
|
| 77 |
+
except Exception:
|
| 78 |
+
await conn.rollback()
|
| 79 |
+
raise
|
| 80 |
+
|
| 81 |
+
async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
|
| 82 |
+
async with self._pool.acquire() as conn:
|
| 83 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 84 |
+
if user_id is None:
|
| 85 |
+
await cur.execute(
|
| 86 |
+
"""
|
| 87 |
+
SELECT f.id AS file_id,
|
| 88 |
+
f.dc_id,
|
| 89 |
+
f.size AS file_size,
|
| 90 |
+
f.mime_type,
|
| 91 |
+
f.file_name,
|
| 92 |
+
f.thumb_size,
|
| 93 |
+
f.is_deleted
|
| 94 |
+
FROM TGFILE f
|
| 95 |
+
WHERE f.id = %s
|
| 96 |
+
LIMIT 1
|
| 97 |
+
""",
|
| 98 |
+
(file_id, )
|
| 99 |
+
)
|
| 100 |
+
else:
|
| 101 |
+
await cur.execute(
|
| 102 |
+
"""
|
| 103 |
+
SELECT f.id AS file_id,
|
| 104 |
+
f.dc_id,
|
| 105 |
+
f.size AS file_size,
|
| 106 |
+
f.mime_type,
|
| 107 |
+
f.file_name,
|
| 108 |
+
f.thumb_size,
|
| 109 |
+
f.is_deleted
|
| 110 |
+
FROM TGFILE f
|
| 111 |
+
WHERE f.id = %s
|
| 112 |
+
AND EXISTS (
|
| 113 |
+
SELECT 1
|
| 114 |
+
FROM USER_FILE uf
|
| 115 |
+
WHERE uf.id = f.id
|
| 116 |
+
AND uf.user_id = %s
|
| 117 |
+
)
|
| 118 |
+
LIMIT 1
|
| 119 |
+
""",
|
| 120 |
+
(file_id, user_id)
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
row = await cur.fetchone()
|
| 124 |
+
if not row:
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
return FileInfo(
|
| 128 |
+
id=int(row["file_id"]),
|
| 129 |
+
dc_id=int(row["dc_id"]),
|
| 130 |
+
file_size=int(row["file_size"]),
|
| 131 |
+
mime_type=row["mime_type"],
|
| 132 |
+
file_name=row["file_name"],
|
| 133 |
+
thumb_size=row["thumb_size"],
|
| 134 |
+
is_deleted=bool(row["is_deleted"]),
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
|
| 138 |
+
async with self._pool.acquire() as conn:
|
| 139 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 140 |
+
await cur.execute(
|
| 141 |
+
"""
|
| 142 |
+
SELECT access_hash, file_reference
|
| 143 |
+
FROM FILE_LOCATION
|
| 144 |
+
WHERE id = %s and bot_id = %s
|
| 145 |
+
LIMIT 1
|
| 146 |
+
""",
|
| 147 |
+
(file.id, bot_id)
|
| 148 |
+
)
|
| 149 |
+
row = await cur.fetchone()
|
| 150 |
+
if not row:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
cls = InputPhotoFileLocation if file.thumb_size else InputDocumentFileLocation
|
| 154 |
+
return cls(
|
| 155 |
+
id=file.id,
|
| 156 |
+
access_hash=int(row["access_hash"]),
|
| 157 |
+
file_reference=row["file_reference"],
|
| 158 |
+
thumb_size=file.thumb_size
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
|
| 162 |
+
async with self._pool.acquire() as conn:
|
| 163 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 164 |
+
await cur.execute(
|
| 165 |
+
"""
|
| 166 |
+
SELECT source_chat_id, source_msg_id, added_at
|
| 167 |
+
FROM USER_FILE
|
| 168 |
+
WHERE id = %s and user_id = %s
|
| 169 |
+
LIMIT 1
|
| 170 |
+
""",
|
| 171 |
+
(file_id, user_id)
|
| 172 |
+
)
|
| 173 |
+
row = await cur.fetchone()
|
| 174 |
+
if not row:
|
| 175 |
+
return None
|
| 176 |
+
return FileSource(
|
| 177 |
+
chat_id=int(row["source_chat_id"]),
|
| 178 |
+
message_id=int(row["source_msg_id"]),
|
| 179 |
+
time=row["added_at"]
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
|
| 183 |
+
async with self._pool.acquire() as conn:
|
| 184 |
+
async with conn.cursor() as cur:
|
| 185 |
+
try:
|
| 186 |
+
await cur.execute(
|
| 187 |
+
"""
|
| 188 |
+
INSERT INTO FILE_LOCATION (bot_id, id, access_hash, file_reference)
|
| 189 |
+
VALUES (%s, %s, %s, %s)
|
| 190 |
+
ON DUPLICATE KEY UPDATE
|
| 191 |
+
access_hash = VALUES(access_hash),
|
| 192 |
+
file_reference = VALUES(file_reference)
|
| 193 |
+
""",
|
| 194 |
+
(bot_id, loc.id, loc.access_hash, loc.file_reference)
|
| 195 |
+
)
|
| 196 |
+
await conn.commit()
|
| 197 |
+
except Exception:
|
| 198 |
+
await conn.rollback()
|
| 199 |
+
raise
|
| 200 |
+
|
| 201 |
+
async def get_files(self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 202 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 203 |
+
|
| 204 |
+
base_sql = """
|
| 205 |
+
SELECT f.id AS file_id, f.file_name
|
| 206 |
+
FROM TGFILE f
|
| 207 |
+
JOIN USER_FILE uf ON f.id = uf.id
|
| 208 |
+
WHERE uf.user_id = %s
|
| 209 |
+
ORDER BY uf.added_at DESC
|
| 210 |
+
"""
|
| 211 |
+
|
| 212 |
+
params = [user_id]
|
| 213 |
+
|
| 214 |
+
if limit is not None:
|
| 215 |
+
base_sql += " LIMIT %s OFFSET %s"
|
| 216 |
+
params.extend([limit, offset])
|
| 217 |
+
|
| 218 |
+
async with self._pool.acquire() as conn:
|
| 219 |
+
async with conn.cursor(aiomysql.SSCursor) as cur:
|
| 220 |
+
await cur.execute(base_sql, params)
|
| 221 |
+
|
| 222 |
+
async for row in cur:
|
| 223 |
+
file_id, file_name = row
|
| 224 |
+
yield int(file_id), str(file_name)
|
| 225 |
+
|
| 226 |
+
async def get_files2(self, user_id: int, file_ids: list[int], full: bool = False,
|
| 227 |
+
) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
|
| 228 |
+
|
| 229 |
+
if not file_ids:
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
placeholders = ",".join(["%s"] * len(file_ids))
|
| 233 |
+
|
| 234 |
+
if full:
|
| 235 |
+
select_clause = "f.*"
|
| 236 |
+
else:
|
| 237 |
+
select_clause = "f.id AS file_id, f.file_name"
|
| 238 |
+
|
| 239 |
+
base_sql = f"""
|
| 240 |
+
SELECT {select_clause}
|
| 241 |
+
FROM TGFILE f
|
| 242 |
+
JOIN USER_FILE uf ON f.id = uf.id
|
| 243 |
+
WHERE uf.user_id = %s
|
| 244 |
+
AND f.id IN ({placeholders})
|
| 245 |
+
ORDER BY uf.added_at DESC
|
| 246 |
+
"""
|
| 247 |
+
|
| 248 |
+
params = [user_id] + file_ids
|
| 249 |
+
|
| 250 |
+
async with self._pool.acquire() as conn:
|
| 251 |
+
async with conn.cursor(aiomysql.SSCursor) as cur:
|
| 252 |
+
await cur.execute(base_sql, params)
|
| 253 |
+
|
| 254 |
+
if full:
|
| 255 |
+
columns = [col[0] for col in cur.description]
|
| 256 |
+
|
| 257 |
+
async for raw_row in cur:
|
| 258 |
+
row = dict(zip(columns, raw_row))
|
| 259 |
+
yield FileInfo(
|
| 260 |
+
id=int(row["id"]),
|
| 261 |
+
dc_id=int(row["dc_id"]),
|
| 262 |
+
file_size=int(row["size"]),
|
| 263 |
+
mime_type=row["mime_type"],
|
| 264 |
+
file_name=row["file_name"],
|
| 265 |
+
thumb_size=row["thumb_size"],
|
| 266 |
+
is_deleted=bool(row["is_deleted"]),
|
| 267 |
+
)
|
| 268 |
+
else:
|
| 269 |
+
async for row in cur:
|
| 270 |
+
file_id, file_name = row
|
| 271 |
+
yield int(file_id), str(file_name)
|
| 272 |
+
|
| 273 |
+
async def get_file_users(self, file_id: int, ) -> set[int]:
|
| 274 |
+
async with self._pool.acquire() as conn:
|
| 275 |
+
async with conn.cursor() as cur:
|
| 276 |
+
await cur.execute(
|
| 277 |
+
"""
|
| 278 |
+
SELECT user_id
|
| 279 |
+
FROM USER_FILE
|
| 280 |
+
WHERE id = %s
|
| 281 |
+
""",
|
| 282 |
+
(file_id)
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
rows = await cur.fetchall()
|
| 286 |
+
return {col[0] for col in rows}
|
| 287 |
+
|
| 288 |
+
async def total_files(self, user_id: int) -> int:
|
| 289 |
+
async with self._pool.acquire() as conn:
|
| 290 |
+
async with conn.cursor() as cur:
|
| 291 |
+
await cur.execute(
|
| 292 |
+
"""
|
| 293 |
+
SELECT COUNT(*)
|
| 294 |
+
FROM USER_FILE
|
| 295 |
+
WHERE user_id = %s
|
| 296 |
+
""",
|
| 297 |
+
(user_id,)
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
row = await cur.fetchone()
|
| 301 |
+
return int(row[0]) if row else 0
|
| 302 |
+
|
| 303 |
+
async def delete_file(self, file_id: int) -> bool:
|
| 304 |
+
async with self._pool.acquire() as conn:
|
| 305 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 306 |
+
try:
|
| 307 |
+
await cur.execute(
|
| 308 |
+
"""
|
| 309 |
+
DELETE FROM TGFILE
|
| 310 |
+
WHERE id = %s
|
| 311 |
+
LIMIT 1
|
| 312 |
+
""",
|
| 313 |
+
(file_id)
|
| 314 |
+
)
|
| 315 |
+
deleted = bool(cur.rowcount > 0)
|
| 316 |
+
await conn.commit()
|
| 317 |
+
return deleted
|
| 318 |
+
except Exception:
|
| 319 |
+
await conn.rollback()
|
| 320 |
+
raise
|
| 321 |
+
|
| 322 |
+
async def remove_file(self, file_id: int, user_id: int) -> bool:
|
| 323 |
+
async with self._pool.acquire() as conn:
|
| 324 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 325 |
+
try:
|
| 326 |
+
await cur.execute(
|
| 327 |
+
"""
|
| 328 |
+
DELETE FROM USER_FILE
|
| 329 |
+
WHERE id = %s AND user_id = %s
|
| 330 |
+
LIMIT 1
|
| 331 |
+
""",
|
| 332 |
+
(file_id, user_id)
|
| 333 |
+
)
|
| 334 |
+
deleted = bool(cur.rowcount > 0)
|
| 335 |
+
await conn.commit()
|
| 336 |
+
return deleted
|
| 337 |
+
except Exception:
|
| 338 |
+
await conn.rollback()
|
| 339 |
+
raise
|
tgfs/database/mysql/group.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
from typing import AsyncGenerator, Optional
|
| 19 |
+
|
| 20 |
+
import aiomysql
|
| 21 |
+
|
| 22 |
+
from tgfs.database.database import BaseStorage
|
| 23 |
+
from tgfs.utils.types import GroupInfo
|
| 24 |
+
|
| 25 |
+
class GroupDB(BaseStorage):
|
| 26 |
+
_list_lock = asyncio.Lock()
|
| 27 |
+
_pool: aiomysql.Pool
|
| 28 |
+
|
| 29 |
+
async def create_group(self, user_id: int, name: str) -> int:
|
| 30 |
+
async with self._pool.acquire() as conn:
|
| 31 |
+
async with conn.cursor() as cur:
|
| 32 |
+
try:
|
| 33 |
+
await cur.execute(
|
| 34 |
+
"""
|
| 35 |
+
INSERT INTO FILE_GROUP (user_id, name)
|
| 36 |
+
VALUES (%s, %s)
|
| 37 |
+
""",
|
| 38 |
+
(user_id, name)
|
| 39 |
+
)
|
| 40 |
+
group_id = cur.lastrowid
|
| 41 |
+
await conn.commit()
|
| 42 |
+
return group_id
|
| 43 |
+
except Exception:
|
| 44 |
+
await conn.rollback()
|
| 45 |
+
raise
|
| 46 |
+
|
| 47 |
+
async def add_file_to_group(self, group_id: int, user_id: int, file_id: int, order: Optional[int] = None) -> None:
|
| 48 |
+
async with self._list_lock:
|
| 49 |
+
async with self._pool.acquire() as conn:
|
| 50 |
+
async with conn.cursor() as cur:
|
| 51 |
+
try:
|
| 52 |
+
if order is None:
|
| 53 |
+
await cur.execute(
|
| 54 |
+
"""
|
| 55 |
+
SELECT COALESCE(MAX(order_index), 0) + 1
|
| 56 |
+
FROM FILE_GROUP_FILE
|
| 57 |
+
WHERE group_id = %s
|
| 58 |
+
FOR UPDATE
|
| 59 |
+
""",
|
| 60 |
+
(group_id,)
|
| 61 |
+
)
|
| 62 |
+
row = await cur.fetchone()
|
| 63 |
+
order = int(row[0]) if row else 1
|
| 64 |
+
|
| 65 |
+
await cur.execute(
|
| 66 |
+
"""
|
| 67 |
+
INSERT INTO FILE_GROUP_FILE (group_id, user_id, id, order_index)
|
| 68 |
+
VALUES (%s, %s, %s, %s)
|
| 69 |
+
ON DUPLICATE KEY UPDATE
|
| 70 |
+
order_index = VALUES(order_index)
|
| 71 |
+
""",
|
| 72 |
+
(group_id, user_id, file_id, order)
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
await conn.commit()
|
| 76 |
+
except Exception:
|
| 77 |
+
await conn.rollback()
|
| 78 |
+
raise
|
| 79 |
+
|
| 80 |
+
async def get_groups(self, user_id: int, offset: int = 0, limit: Optional[int] = None
|
| 81 |
+
) -> AsyncGenerator[tuple[int, str], None]:
|
| 82 |
+
base_sql = """
|
| 83 |
+
SELECT group_id, name
|
| 84 |
+
FROM FILE_GROUP
|
| 85 |
+
WHERE user_id = %s
|
| 86 |
+
ORDER BY created_at DESC
|
| 87 |
+
"""
|
| 88 |
+
params = [user_id]
|
| 89 |
+
|
| 90 |
+
if limit is not None:
|
| 91 |
+
base_sql += " LIMIT %s OFFSET %s"
|
| 92 |
+
params.extend([limit, offset])
|
| 93 |
+
|
| 94 |
+
async with self._pool.acquire() as conn:
|
| 95 |
+
async with conn.cursor(aiomysql.SSCursor) as cur:
|
| 96 |
+
await cur.execute(base_sql, params)
|
| 97 |
+
async for row in cur:
|
| 98 |
+
group_id, name = row
|
| 99 |
+
yield int(group_id), str(name)
|
| 100 |
+
|
| 101 |
+
async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
|
| 102 |
+
async with self._pool.acquire() as conn:
|
| 103 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 104 |
+
await cur.execute(
|
| 105 |
+
"""
|
| 106 |
+
SELECT group_id, user_id, name, created_at
|
| 107 |
+
FROM FILE_GROUP
|
| 108 |
+
WHERE group_id = %s AND user_id = %s
|
| 109 |
+
LIMIT 1
|
| 110 |
+
""",
|
| 111 |
+
(group_id, user_id)
|
| 112 |
+
)
|
| 113 |
+
row = await cur.fetchone()
|
| 114 |
+
if not row:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
gi = GroupInfo(
|
| 118 |
+
group_id=int(row["group_id"]),
|
| 119 |
+
user_id=int(row["user_id"]),
|
| 120 |
+
name=row["name"],
|
| 121 |
+
created_at=row.get("created_at"),
|
| 122 |
+
files=[]
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
await cur.execute(
|
| 126 |
+
"""
|
| 127 |
+
SELECT id
|
| 128 |
+
FROM FILE_GROUP_FILE
|
| 129 |
+
WHERE group_id = %s
|
| 130 |
+
ORDER BY order_index ASC
|
| 131 |
+
""",
|
| 132 |
+
(group_id,)
|
| 133 |
+
)
|
| 134 |
+
rows = await cur.fetchall()
|
| 135 |
+
if not rows:
|
| 136 |
+
return gi
|
| 137 |
+
|
| 138 |
+
gi.files.extend([int(r["id"]) for r in rows])
|
| 139 |
+
return gi
|
| 140 |
+
|
| 141 |
+
async def delete_group(self, group_id: int, user_id: int) -> None:
|
| 142 |
+
async with self._pool.acquire() as conn:
|
| 143 |
+
async with conn.cursor() as cur:
|
| 144 |
+
try:
|
| 145 |
+
await cur.execute(
|
| 146 |
+
"""
|
| 147 |
+
DELETE FROM FILE_GROUP
|
| 148 |
+
WHERE group_id = %s AND user_id = %s
|
| 149 |
+
""",
|
| 150 |
+
(group_id, user_id)
|
| 151 |
+
)
|
| 152 |
+
await conn.commit()
|
| 153 |
+
except Exception:
|
| 154 |
+
await conn.rollback()
|
| 155 |
+
raise
|
| 156 |
+
|
| 157 |
+
async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
|
| 158 |
+
async with self._pool.acquire() as conn:
|
| 159 |
+
async with conn.cursor() as cur:
|
| 160 |
+
try:
|
| 161 |
+
await cur.execute(
|
| 162 |
+
"""
|
| 163 |
+
UPDATE FILE_GROUP
|
| 164 |
+
SET name = %s
|
| 165 |
+
WHERE group_id = %s AND user_id = %s
|
| 166 |
+
""",
|
| 167 |
+
(name, group_id, user_id)
|
| 168 |
+
)
|
| 169 |
+
await conn.commit()
|
| 170 |
+
except Exception:
|
| 171 |
+
await conn.rollback()
|
| 172 |
+
raise
|
| 173 |
+
|
| 174 |
+
async def update_group_order(self, group_id: int, file_id: int, user_id: int, new_order: int) -> None:
|
| 175 |
+
async with self._pool.acquire() as conn:
|
| 176 |
+
async with conn.cursor() as cur:
|
| 177 |
+
try:
|
| 178 |
+
await cur.execute(
|
| 179 |
+
"""
|
| 180 |
+
UPDATE FILE_GROUP_FILE gff
|
| 181 |
+
JOIN FILE_GROUP fg
|
| 182 |
+
ON fg.group_id = gff.group_id
|
| 183 |
+
SET gff.order_index = %s
|
| 184 |
+
WHERE gff.group_id = %s
|
| 185 |
+
AND gff.id = %s
|
| 186 |
+
AND fg.user_id = %s
|
| 187 |
+
""",
|
| 188 |
+
(new_order, group_id, file_id, user_id)
|
| 189 |
+
)
|
| 190 |
+
await conn.commit()
|
| 191 |
+
except Exception:
|
| 192 |
+
await conn.rollback()
|
| 193 |
+
raise
|
| 194 |
+
async def total_groups(self, user_id: int) -> int:
|
| 195 |
+
async with self._pool.acquire() as conn:
|
| 196 |
+
async with conn.cursor() as cur:
|
| 197 |
+
await cur.execute(
|
| 198 |
+
"""
|
| 199 |
+
SELECT COUNT(*)
|
| 200 |
+
FROM FILE_GROUP
|
| 201 |
+
WHERE user_id = %s
|
| 202 |
+
""",
|
| 203 |
+
(user_id)
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
row = await cur.fetchone()
|
| 207 |
+
return int(row[0]) if row else 0
|
tgfs/database/mysql/schema.sql
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE TABLE IF NOT EXISTS TGFILE (
|
| 2 |
+
id BIGINT UNSIGNED PRIMARY KEY,
|
| 3 |
+
dc_id TINYINT UNSIGNED NOT NULL,
|
| 4 |
+
size BIGINT UNSIGNED NOT NULL,
|
| 5 |
+
mime_type VARCHAR(255),
|
| 6 |
+
file_name VARCHAR(255),
|
| 7 |
+
thumb_size CHAR(1),
|
| 8 |
+
is_deleted BOOLEAN DEFAULT FALSE
|
| 9 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 10 |
+
|
| 11 |
+
CREATE TABLE IF NOT EXISTS FILE_LOCATION (
|
| 12 |
+
bot_id BIGINT UNSIGNED NOT NULL,
|
| 13 |
+
id BIGINT UNSIGNED NOT NULL,
|
| 14 |
+
access_hash BIGINT NULL,
|
| 15 |
+
file_reference BLOB NULL,
|
| 16 |
+
PRIMARY KEY (bot_id, id),
|
| 17 |
+
CONSTRAINT fk_file_ids_files FOREIGN KEY (id) REFERENCES TGFILE(id) ON DELETE CASCADE
|
| 18 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 19 |
+
|
| 20 |
+
CREATE TABLE IF NOT EXISTS TGUSER (
|
| 21 |
+
user_id BIGINT UNSIGNED PRIMARY KEY,
|
| 22 |
+
join_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 23 |
+
ban_date TIMESTAMP NULL,
|
| 24 |
+
warns TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
| 25 |
+
preferred_lang CHAR(2) NOT NULL DEFAULT 'en',
|
| 26 |
+
curt_op TINYINT UNSIGNED DEFAULT 0,
|
| 27 |
+
op_id BIGINT DEFAULT 0
|
| 28 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 29 |
+
|
| 30 |
+
CREATE TABLE IF NOT EXISTS USER_FILE (
|
| 31 |
+
user_id BIGINT UNSIGNED NOT NULL,
|
| 32 |
+
id BIGINT UNSIGNED NOT NULL,
|
| 33 |
+
source_chat_id BIGINT NULL,
|
| 34 |
+
source_msg_id BIGINT UNSIGNED NULL,
|
| 35 |
+
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 36 |
+
PRIMARY KEY (user_id, id),
|
| 37 |
+
CONSTRAINT fk_user_files_users FOREIGN KEY (user_id) REFERENCES TGUSER(user_id) ON DELETE CASCADE,
|
| 38 |
+
CONSTRAINT fk_user_files_files FOREIGN KEY (id) REFERENCES TGFILE(id) ON DELETE RESTRICT
|
| 39 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 40 |
+
|
| 41 |
+
CREATE TABLE IF NOT EXISTS FILE_GROUP (
|
| 42 |
+
group_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
| 43 |
+
user_id BIGINT UNSIGNED NOT NULL,
|
| 44 |
+
name VARCHAR(255) NOT NULL,
|
| 45 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 46 |
+
CONSTRAINT fk_file_group_owner FOREIGN KEY (user_id) REFERENCES TGUSER(user_id) ON DELETE CASCADE
|
| 47 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
| 48 |
+
|
| 49 |
+
CREATE TABLE IF NOT EXISTS FILE_GROUP_FILE (
|
| 50 |
+
group_id BIGINT UNSIGNED NOT NULL,
|
| 51 |
+
user_id BIGINT UNSIGNED NOT NULL,
|
| 52 |
+
id BIGINT UNSIGNED NOT NULL,
|
| 53 |
+
order_index INT UNSIGNED NOT NULL DEFAULT 0,
|
| 54 |
+
PRIMARY KEY (group_id, user_id, id),
|
| 55 |
+
CONSTRAINT fk_fg_group FOREIGN KEY (group_id) REFERENCES FILE_GROUP(group_id) ON DELETE CASCADE,
|
| 56 |
+
CONSTRAINT fk_fg_user_file FOREIGN KEY (user_id, id) REFERENCES USER_FILE(user_id, id) ON DELETE CASCADE
|
| 57 |
+
) ENGINE=InnoDB;
|
| 58 |
+
|
| 59 |
+
CREATE TABLE IF NOT EXISTS APP_CONFIG (
|
| 60 |
+
k VARCHAR(64) PRIMARY KEY,
|
| 61 |
+
v BLOB NOT NULL,
|
| 62 |
+
type ENUM('bytes', 'bool', 'int', 'str', 'json') NOT NULL
|
| 63 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
tgfs/database/mysql/user.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
from typing import AsyncGenerator, Optional
|
| 18 |
+
|
| 19 |
+
import aiomysql
|
| 20 |
+
|
| 21 |
+
from tgfs.database.database import BaseStorage
|
| 22 |
+
from tgfs.utils.types import User
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class UserDB(BaseStorage):
|
| 26 |
+
_pool: aiomysql.Pool
|
| 27 |
+
|
| 28 |
+
async def get_user(self, user_id: int) -> Optional[User]:
|
| 29 |
+
async with self._pool.acquire() as conn:
|
| 30 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 31 |
+
await cur.execute(
|
| 32 |
+
"""
|
| 33 |
+
SELECT user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id
|
| 34 |
+
FROM TGUSER WHERE user_id = %s
|
| 35 |
+
""",
|
| 36 |
+
(user_id,)
|
| 37 |
+
)
|
| 38 |
+
row = await cur.fetchone()
|
| 39 |
+
if not row:
|
| 40 |
+
return None
|
| 41 |
+
return User.from_row(row)
|
| 42 |
+
|
| 43 |
+
async def add_user(self, user_id: int) -> bool:
|
| 44 |
+
async with self._pool.acquire() as conn:
|
| 45 |
+
async with conn.cursor() as cur:
|
| 46 |
+
try:
|
| 47 |
+
await cur.execute(
|
| 48 |
+
"INSERT IGNORE INTO TGUSER (user_id) VALUES (%s)",
|
| 49 |
+
(user_id,)
|
| 50 |
+
)
|
| 51 |
+
inserted = cur.rowcount > 0
|
| 52 |
+
await conn.commit()
|
| 53 |
+
return bool(inserted)
|
| 54 |
+
except Exception:
|
| 55 |
+
await conn.rollback()
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
async def upsert_user(self, user: User) -> bool:
|
| 59 |
+
async with self._pool.acquire() as conn:
|
| 60 |
+
async with conn.cursor() as cur:
|
| 61 |
+
try:
|
| 62 |
+
await cur.execute(
|
| 63 |
+
"""
|
| 64 |
+
INSERT INTO TGUSER (user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id)
|
| 65 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s) AS new
|
| 66 |
+
ON DUPLICATE KEY UPDATE
|
| 67 |
+
join_date = new.join_date,
|
| 68 |
+
ban_date = new.ban_date,
|
| 69 |
+
warns = new.warns,
|
| 70 |
+
preferred_lang = new.preferred_lang,
|
| 71 |
+
curt_op = new.curt_op,
|
| 72 |
+
op_id = new.op_id
|
| 73 |
+
""",
|
| 74 |
+
(user.user_id, user.join_date, user.ban_date, user.warns,
|
| 75 |
+
user.preferred_lang, user.curt_op.value, user.op_id)
|
| 76 |
+
)
|
| 77 |
+
await conn.commit()
|
| 78 |
+
return True
|
| 79 |
+
except Exception:
|
| 80 |
+
await conn.rollback()
|
| 81 |
+
raise
|
| 82 |
+
|
| 83 |
+
async def delete_user(self, user_id: int) -> bool:
|
| 84 |
+
async with self._pool.acquire() as conn:
|
| 85 |
+
async with conn.cursor() as cur:
|
| 86 |
+
try:
|
| 87 |
+
await cur.execute("DELETE FROM TGUSER WHERE user_id = %s", (user_id,))
|
| 88 |
+
deleted = cur.rowcount > 0
|
| 89 |
+
await conn.commit()
|
| 90 |
+
return bool(deleted)
|
| 91 |
+
except Exception:
|
| 92 |
+
await conn.rollback()
|
| 93 |
+
raise
|
| 94 |
+
|
| 95 |
+
async def get_users(self) -> AsyncGenerator[User, None]:
|
| 96 |
+
async with self._pool.acquire() as conn:
|
| 97 |
+
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 98 |
+
await cur.execute(
|
| 99 |
+
"SELECT user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id FROM TGUSER"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
while True:
|
| 103 |
+
row = await cur.fetchone()
|
| 104 |
+
if not row:
|
| 105 |
+
break
|
| 106 |
+
|
| 107 |
+
yield User.from_row(row)
|
| 108 |
+
|
| 109 |
+
async def count_users(self) -> int:
|
| 110 |
+
async with self._pool.acquire() as conn:
|
| 111 |
+
async with conn.cursor() as cur:
|
| 112 |
+
await cur.execute("SELECT COUNT(*) FROM TGUSER")
|
| 113 |
+
(count,) = await cur.fetchone()
|
| 114 |
+
return int(count)
|
tgfs/database/mysql/utils.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import aiomysql
|
| 23 |
+
|
| 24 |
+
from tgfs.database.database import BaseStorage
|
| 25 |
+
from tgfs.utils.types import SupportedType
|
| 26 |
+
|
| 27 |
+
def encode_value(value: SupportedType) -> tuple[bytes, str]:
|
| 28 |
+
if isinstance(value, bytes):
|
| 29 |
+
return value, "bytes"
|
| 30 |
+
|
| 31 |
+
if isinstance(value, str):
|
| 32 |
+
return value.encode("utf-8"), "str"
|
| 33 |
+
|
| 34 |
+
if isinstance(value, bool):
|
| 35 |
+
return (b"\x01" if value else b"\x00"), "bool"
|
| 36 |
+
|
| 37 |
+
if isinstance(value, int):
|
| 38 |
+
size = max(1, (value.bit_length() + 7) // 8)
|
| 39 |
+
return value.to_bytes(size, "big", signed=True), "int"
|
| 40 |
+
|
| 41 |
+
if isinstance(value, (dict, list)):
|
| 42 |
+
return json.dumps(value, separators=(",", ":")).encode("utf-8"), "json"
|
| 43 |
+
|
| 44 |
+
raise TypeError(f"Unsupported type: {type(value)}")
|
| 45 |
+
|
| 46 |
+
def decode_value(data: bytes, vtype: str) -> SupportedType:
|
| 47 |
+
if vtype == "bytes":
|
| 48 |
+
return data
|
| 49 |
+
|
| 50 |
+
if vtype == "str":
|
| 51 |
+
return data.decode("utf-8")
|
| 52 |
+
|
| 53 |
+
if vtype == "bool":
|
| 54 |
+
return data != b"\x00"
|
| 55 |
+
|
| 56 |
+
if vtype == "int":
|
| 57 |
+
return int.from_bytes(data, "big", signed=True)
|
| 58 |
+
|
| 59 |
+
if vtype == "json":
|
| 60 |
+
return json.loads(data.decode("utf-8"))
|
| 61 |
+
|
| 62 |
+
raise ValueError(f"Unknown type: {vtype}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class UtilDB(BaseStorage):
|
| 66 |
+
_pool: aiomysql.Pool
|
| 67 |
+
|
| 68 |
+
async def get_secret(self, rotate=False) -> bytes:
|
| 69 |
+
async with self._pool.acquire() as conn:
|
| 70 |
+
async with conn.cursor() as cur:
|
| 71 |
+
if not rotate:
|
| 72 |
+
await cur.execute(
|
| 73 |
+
"SELECT v FROM APP_CONFIG WHERE k = %s",
|
| 74 |
+
("link.secret",)
|
| 75 |
+
)
|
| 76 |
+
row: bytes = await cur.fetchone()
|
| 77 |
+
if row:
|
| 78 |
+
return row[0]
|
| 79 |
+
|
| 80 |
+
secret = os.urandom(32)
|
| 81 |
+
await cur.execute(
|
| 82 |
+
"""
|
| 83 |
+
INSERT INTO APP_CONFIG (k, v)
|
| 84 |
+
VALUES (%s, %s)
|
| 85 |
+
ON DUPLICATE KEY UPDATE
|
| 86 |
+
v = VALUES(v)
|
| 87 |
+
""",
|
| 88 |
+
("link.secret", secret)
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
await conn.commit()
|
| 92 |
+
return secret
|
| 93 |
+
|
| 94 |
+
async def get_config_value(self, key: str) -> Optional[SupportedType]:
|
| 95 |
+
async with self._pool.acquire() as conn:
|
| 96 |
+
async with conn.cursor() as cur:
|
| 97 |
+
await cur.execute(
|
| 98 |
+
"SELECT v, type FROM APP_CONFIG WHERE k = %s",
|
| 99 |
+
(key,)
|
| 100 |
+
)
|
| 101 |
+
row = await cur.fetchone()
|
| 102 |
+
if not row:
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
data, vtype = row
|
| 106 |
+
return decode_value(data, vtype)
|
| 107 |
+
|
| 108 |
+
async def set_config_value(self, key: str, value: SupportedType) -> None:
|
| 109 |
+
data, vtype = encode_value(value)
|
| 110 |
+
|
| 111 |
+
async with self._pool.acquire() as conn:
|
| 112 |
+
async with conn.cursor() as cur:
|
| 113 |
+
try:
|
| 114 |
+
await cur.execute(
|
| 115 |
+
"""
|
| 116 |
+
INSERT INTO APP_CONFIG (k, v, type)
|
| 117 |
+
VALUES (%s, %s, %s)
|
| 118 |
+
ON DUPLICATE KEY UPDATE
|
| 119 |
+
v = VALUES(v),
|
| 120 |
+
type = VALUES(type)
|
| 121 |
+
""",
|
| 122 |
+
(key, data, vtype)
|
| 123 |
+
)
|
| 124 |
+
await conn.commit()
|
| 125 |
+
except Exception:
|
| 126 |
+
await conn.rollback()
|
| 127 |
+
raise
|
tgfs/info.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__version__="0.0.1"
|
| 2 |
+
class Version:
|
| 3 |
+
version = __version__.split(".", maxsplit=3)
|
| 4 |
+
major = int(version[0])
|
| 5 |
+
minor = int(version[1])
|
| 6 |
+
patch = int(version[2])
|
| 7 |
+
|
| 8 |
+
def __str__(self) -> str:
|
| 9 |
+
return f"{self.major}.{self.minor}.{self.patch}"
|
tgfs/log.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from tgfs.config import Config
|
| 19 |
+
|
| 20 |
+
LOG_LEVEL = logging.DEBUG if Config.DEBUG else logging.INFO
|
| 21 |
+
EXT_LOG_LEVEL = logging.INFO if Config.EXT_DEBUG else logging.ERROR
|
| 22 |
+
logging.basicConfig(level=LOG_LEVEL)
|
| 23 |
+
logging.getLogger("telethon").setLevel(EXT_LOG_LEVEL)
|
| 24 |
+
logging.getLogger("aiohttp").setLevel(EXT_LOG_LEVEL)
|
| 25 |
+
logging.getLogger("asyncio").setLevel(EXT_LOG_LEVEL)
|
| 26 |
+
logging.getLogger("pymongo").setLevel(EXT_LOG_LEVEL)
|
| 27 |
+
logging.getLogger("aiomysql").setLevel(EXT_LOG_LEVEL)
|
| 28 |
+
log = logging.getLogger("tgstream")
|
tgfs/paralleltransfer.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2019 Tulir Asokan
|
| 3 |
+
#
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
#
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
#
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
# Modifications made by Deekshith SH, 2025-2026
|
| 18 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 19 |
+
|
| 20 |
+
# pylint: disable=protected-access
|
| 21 |
+
|
| 22 |
+
import copy
|
| 23 |
+
import logging
|
| 24 |
+
import asyncio
|
| 25 |
+
import math
|
| 26 |
+
from collections import defaultdict
|
| 27 |
+
from contextlib import asynccontextmanager
|
| 28 |
+
from dataclasses import dataclass
|
| 29 |
+
from typing import AsyncGenerator, Optional
|
| 30 |
+
|
| 31 |
+
from telethon import TelegramClient
|
| 32 |
+
from telethon.crypto import AuthKey
|
| 33 |
+
from telethon.network import MTProtoSender
|
| 34 |
+
from telethon.tl.alltlobjects import LAYER
|
| 35 |
+
from telethon.tl.functions import InvokeWithLayerRequest
|
| 36 |
+
from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
|
| 37 |
+
from telethon.tl.functions.upload import GetFileRequest
|
| 38 |
+
from telethon.tl.types import DcOption
|
| 39 |
+
from telethon.errors import DcIdInvalidError
|
| 40 |
+
|
| 41 |
+
from tgfs.config import Config
|
| 42 |
+
from tgfs.utils.types import InputTypeLocation
|
| 43 |
+
|
| 44 |
+
root_log = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
if Config.CONNECTION_LIMIT > 25:
|
| 47 |
+
root_log.warning("The connection limit should not be set above 25 to avoid"
|
| 48 |
+
" infinite disconnect/reconnect loops")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class Connection:
|
| 53 |
+
log: logging.Logger
|
| 54 |
+
sender: MTProtoSender
|
| 55 |
+
lock: asyncio.Lock
|
| 56 |
+
users: int = 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DCConnectionManager:
|
| 60 |
+
log: logging.Logger
|
| 61 |
+
client: TelegramClient
|
| 62 |
+
|
| 63 |
+
dc_id: int
|
| 64 |
+
dc: Optional[DcOption]
|
| 65 |
+
auth_key: Optional[AuthKey]
|
| 66 |
+
connections: list[Connection]
|
| 67 |
+
|
| 68 |
+
_list_lock: asyncio.Lock
|
| 69 |
+
|
| 70 |
+
def __init__(self, client: TelegramClient, dc_id: int, parent_log: logging.Logger) -> None:
|
| 71 |
+
self.log = parent_log.getChild(f"dc{dc_id}")
|
| 72 |
+
self.client = client
|
| 73 |
+
self.dc_id = dc_id
|
| 74 |
+
self.auth_key = None
|
| 75 |
+
self.connections = []
|
| 76 |
+
self._list_lock = asyncio.Lock()
|
| 77 |
+
self.dc = None
|
| 78 |
+
|
| 79 |
+
async def _new_connection(self) -> Connection:
|
| 80 |
+
if not self.dc:
|
| 81 |
+
self.dc = await self.client._get_dc(self.dc_id)
|
| 82 |
+
sender = MTProtoSender(self.auth_key, loggers=self.client._log)
|
| 83 |
+
index = len(self.connections) + 1
|
| 84 |
+
conn = Connection(sender=sender, log=self.log.getChild(
|
| 85 |
+
f"conn{index}"), lock=asyncio.Lock())
|
| 86 |
+
self.connections.append(conn)
|
| 87 |
+
try:
|
| 88 |
+
async with conn.lock:
|
| 89 |
+
conn.log.info("Connecting...")
|
| 90 |
+
connection_info = self.client._connection(self.dc.ip_address, self.dc.port, self.dc.id,
|
| 91 |
+
loggers=self.client._log,
|
| 92 |
+
proxy=self.client._proxy)
|
| 93 |
+
await sender.connect(connection_info)
|
| 94 |
+
if not self.auth_key:
|
| 95 |
+
await self._export_auth_key(conn)
|
| 96 |
+
return conn
|
| 97 |
+
except Exception as e:
|
| 98 |
+
self.connections.remove(conn)
|
| 99 |
+
raise e
|
| 100 |
+
|
| 101 |
+
async def _export_auth_key(self, conn: Connection) -> None:
|
| 102 |
+
self.log.info("Exporting auth to DC %s"
|
| 103 |
+
" (main client is in %s)", self.dc.id, self.client.session.dc_id)
|
| 104 |
+
try:
|
| 105 |
+
auth = await self.client(ExportAuthorizationRequest(self.dc.id))
|
| 106 |
+
except DcIdInvalidError:
|
| 107 |
+
self.log.debug("Got DcIdInvalidError")
|
| 108 |
+
self.auth_key = self.client.session.auth_key
|
| 109 |
+
conn.sender.auth_key = self.auth_key
|
| 110 |
+
return
|
| 111 |
+
init_request = copy.copy(self.client._init_request)
|
| 112 |
+
init_request.query = ImportAuthorizationRequest(
|
| 113 |
+
id=auth.id, bytes=auth.bytes
|
| 114 |
+
)
|
| 115 |
+
req = InvokeWithLayerRequest(
|
| 116 |
+
LAYER, init_request
|
| 117 |
+
)
|
| 118 |
+
await conn.sender.send(req)
|
| 119 |
+
self.auth_key = conn.sender.auth_key
|
| 120 |
+
|
| 121 |
+
async def _next_connection(self) -> Connection:
|
| 122 |
+
best_conn: Optional[Connection] = None
|
| 123 |
+
for conn in self.connections:
|
| 124 |
+
if not best_conn or conn.users < best_conn.users:
|
| 125 |
+
best_conn = conn
|
| 126 |
+
if (not best_conn or best_conn.users > 0) and len(self.connections) < Config.CONNECTION_LIMIT:
|
| 127 |
+
best_conn = await self._new_connection()
|
| 128 |
+
return best_conn
|
| 129 |
+
|
| 130 |
+
@asynccontextmanager
|
| 131 |
+
async def get_connection(self) -> AsyncGenerator[Connection, None]:
|
| 132 |
+
async with self._list_lock:
|
| 133 |
+
conn: Connection = await asyncio.shield(self._next_connection())
|
| 134 |
+
# The connection is locked so reconnections don't stack
|
| 135 |
+
async with conn.lock:
|
| 136 |
+
conn.users += 1
|
| 137 |
+
try:
|
| 138 |
+
yield conn
|
| 139 |
+
finally:
|
| 140 |
+
conn.users -= 1
|
| 141 |
+
|
| 142 |
+
async def disconnect(self) -> None:
|
| 143 |
+
async with self._list_lock:
|
| 144 |
+
await asyncio.gather(*[conn.sender.disconnect() for conn in self.connections])
|
| 145 |
+
self.connections.clear()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class ParallelTransferrer:
|
| 149 |
+
log: logging.Logger
|
| 150 |
+
client: TelegramClient
|
| 151 |
+
client_id: int
|
| 152 |
+
dc_managers: dict[int, DCConnectionManager]
|
| 153 |
+
users: int
|
| 154 |
+
|
| 155 |
+
def __init__(self, client: TelegramClient, client_id: int) -> None:
|
| 156 |
+
self.log = root_log.getChild(f"bot{client_id}")
|
| 157 |
+
self.client = client
|
| 158 |
+
self.client_id = client_id
|
| 159 |
+
self.users = 0
|
| 160 |
+
|
| 161 |
+
self.dc_managers = defaultdict(lambda: None)
|
| 162 |
+
|
| 163 |
+
def _get_dc_manager(self, dc_id: int) -> DCConnectionManager:
|
| 164 |
+
if self.dc_managers[dc_id] is None:
|
| 165 |
+
self.dc_managers[dc_id] = DCConnectionManager(self.client, dc_id, self.log)
|
| 166 |
+
return self.dc_managers[dc_id]
|
| 167 |
+
|
| 168 |
+
def post_init(self) -> None:
|
| 169 |
+
# Pre-initialize the manager for the current session's DC
|
| 170 |
+
manager = self._get_dc_manager(self.client.session.dc_id)
|
| 171 |
+
manager.auth_key = self.client.session.auth_key
|
| 172 |
+
|
| 173 |
+
async def close_connection(self) -> None:
|
| 174 |
+
tasks = []
|
| 175 |
+
for dcid, dcm in self.dc_managers.items():
|
| 176 |
+
if dcm and dcm.connections:
|
| 177 |
+
self.log.debug("Closing connections for DC %d", dcid)
|
| 178 |
+
tasks.append(dcm.disconnect())
|
| 179 |
+
if tasks:
|
| 180 |
+
await asyncio.gather(*tasks)
|
| 181 |
+
self.log.debug("All DC connections closed")
|
| 182 |
+
|
| 183 |
+
async def _int_download(self, request: GetFileRequest, first_part: int, last_part: int,
|
| 184 |
+
part_count: int, part_size: int, dc_id: int, first_part_cut: int,
|
| 185 |
+
last_part_cut: int) -> AsyncGenerator[bytes, None]:
|
| 186 |
+
log = self.log
|
| 187 |
+
self.users += 1
|
| 188 |
+
try:
|
| 189 |
+
part = first_part
|
| 190 |
+
dcm = self._get_dc_manager(dc_id)
|
| 191 |
+
async with dcm.get_connection() as conn:
|
| 192 |
+
log = conn.log
|
| 193 |
+
while part <= last_part:
|
| 194 |
+
result = await self.client._call(conn.sender, request)
|
| 195 |
+
|
| 196 |
+
if not result.bytes:
|
| 197 |
+
break
|
| 198 |
+
|
| 199 |
+
request.offset += part_size
|
| 200 |
+
if last_part == first_part:
|
| 201 |
+
yield result.bytes[first_part_cut:last_part_cut]
|
| 202 |
+
elif part == first_part:
|
| 203 |
+
yield result.bytes[first_part_cut:]
|
| 204 |
+
elif part == last_part:
|
| 205 |
+
yield result.bytes[:last_part_cut]
|
| 206 |
+
else:
|
| 207 |
+
yield result.bytes
|
| 208 |
+
log.debug("Part %d/%d (total %d) downloaded", part, last_part, part_count)
|
| 209 |
+
part += 1
|
| 210 |
+
log.info("Parallel download finished")
|
| 211 |
+
except (GeneratorExit, StopAsyncIteration, asyncio.CancelledError):
|
| 212 |
+
log.info("Parallel download interrupted")
|
| 213 |
+
raise
|
| 214 |
+
except Exception: # pylint: disable=W0718
|
| 215 |
+
log.error("Parallel download errored", exc_info=True)
|
| 216 |
+
finally:
|
| 217 |
+
self.users -= 1
|
| 218 |
+
|
| 219 |
+
def download(self, location: InputTypeLocation, dc_id: int, file_size: int, offset: int, limit: int
|
| 220 |
+
) -> AsyncGenerator[bytes, None]:
|
| 221 |
+
part_size = Config.DOWNLOAD_PART_SIZE
|
| 222 |
+
first_part_cut = offset % part_size
|
| 223 |
+
first_part = math.floor(offset / part_size)
|
| 224 |
+
last_part_cut = (limit % part_size) + 1
|
| 225 |
+
last_part = math.ceil(limit / part_size)
|
| 226 |
+
part_count = math.ceil(file_size / part_size)
|
| 227 |
+
self.log.info("Starting parallel download: chunks %d-%d of %d %s",
|
| 228 |
+
first_part, last_part, part_count, location)
|
| 229 |
+
request = GetFileRequest(location, offset=first_part * part_size, limit=part_size)
|
| 230 |
+
|
| 231 |
+
return self._int_download(
|
| 232 |
+
request, first_part, last_part, part_count, part_size, dc_id,
|
| 233 |
+
first_part_cut, last_part_cut
|
| 234 |
+
)
|
tgfs/patches/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Monkey patches and optional runtime overrides.
|
| 2 |
+
|
| 3 |
+
# Importing this package applies all enabled patches.
|
| 4 |
+
# Currently empty.
|
tgfs/plugins/admin.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import datetime
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from telethon import events
|
| 22 |
+
|
| 23 |
+
from tgfs.config import Config
|
| 24 |
+
from tgfs.telegram import client
|
| 25 |
+
from tgfs.database import DB
|
| 26 |
+
from tgfs.utils.utils import parse_token
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def is_admin(user_id: int):
|
| 30 |
+
return user_id in Config.ADMIN_IDS
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_userid(token: str) -> Optional[int]:
|
| 34 |
+
if token.isdigit():
|
| 35 |
+
return int(token)
|
| 36 |
+
else:
|
| 37 |
+
data = parse_token(token)
|
| 38 |
+
if data:
|
| 39 |
+
return data[0]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
log = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@client.on(events.NewMessage(
|
| 46 |
+
incoming=True,
|
| 47 |
+
pattern=r"^/help",
|
| 48 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 49 |
+
))
|
| 50 |
+
async def handle_help_command(evt: events.NewMessage.Event) -> None:
|
| 51 |
+
await evt.reply(f"""
|
| 52 |
+
Admin Commands:
|
| 53 |
+
/ban <userid> <reason>: ban a user
|
| 54 |
+
/warn <userid> <reason>: warn a user; after {Config.MAX_WARNS} warnings, the user will be banned
|
| 55 |
+
/unban <userid>: unban a user
|
| 56 |
+
/clearwarns <userid>: clear all warnings for a user
|
| 57 |
+
|
| 58 |
+
/listfiles <fileid | token>: list users who generated a link for a specific file
|
| 59 |
+
/restrictfile <fileid | token>: restrict a file link to prevent users from downloading the file
|
| 60 |
+
/deletefile <userid> <fileid>: delete a file associated with a user
|
| 61 |
+
/deletefile <token>: delete a file using its token
|
| 62 |
+
/parsetoken <token>: parse a token to retrieve the file ID and user ID
|
| 63 |
+
|
| 64 |
+
download url strcture: "http://<PUBLIC_URL>/dl/<token>/<signature>"
|
| 65 |
+
""")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@client.on(events.NewMessage(
|
| 69 |
+
incoming=True,
|
| 70 |
+
pattern=r"^/ban (\d+) (.+)$",
|
| 71 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 72 |
+
))
|
| 73 |
+
async def handle_ban_command(evt: events.NewMessage.Event) -> None:
|
| 74 |
+
user_id = int(evt.pattern_match.group(1))
|
| 75 |
+
reason = str(evt.pattern_match.group(2))
|
| 76 |
+
user = await DB.db.get_user(user_id)
|
| 77 |
+
if not user:
|
| 78 |
+
return await evt.reply("User doesn't exist in Database")
|
| 79 |
+
user.ban_date = datetime.datetime.now(datetime.UTC)
|
| 80 |
+
if await DB.db.upsert_user(user):
|
| 81 |
+
await evt.reply(f"Banned user {user_id}")
|
| 82 |
+
await client.send_message(user_id, f"Your banned\nReason: {reason}")
|
| 83 |
+
else:
|
| 84 |
+
await evt.reply(f"Unable to ban user {user_id}")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@client.on(events.NewMessage(
|
| 88 |
+
incoming=True,
|
| 89 |
+
pattern=r"^/warn (\d+) (.+)$",
|
| 90 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 91 |
+
))
|
| 92 |
+
async def handle_warn_command(evt: events.NewMessage.Event) -> None:
|
| 93 |
+
user_id = int(evt.pattern_match.group(1))
|
| 94 |
+
reason = str(evt.pattern_match.group(2))
|
| 95 |
+
user = await DB.db.get_user(user_id)
|
| 96 |
+
if not user:
|
| 97 |
+
return await evt.reply("User doesn't exist in Database")
|
| 98 |
+
user.warns += 1
|
| 99 |
+
if user.warns >= Config.MAX_WARNS:
|
| 100 |
+
await evt.reply("User reached max warns")
|
| 101 |
+
user.ban_date = datetime.datetime.now(datetime.UTC)
|
| 102 |
+
if await DB.db.upsert_user(user):
|
| 103 |
+
await evt.reply(f"User has {user.warns}/{Config.MAX_WARNS} Warns")
|
| 104 |
+
await client.send_message(user_id, f"You have {user.warns}/{Config.MAX_WARNS} Warns\nReason: {reason}")
|
| 105 |
+
else:
|
| 106 |
+
await evt.reply(f"Unable to warn user {user_id}")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@client.on(events.NewMessage(
|
| 110 |
+
incoming=True,
|
| 111 |
+
pattern=r"^/unban (\d+)$",
|
| 112 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 113 |
+
))
|
| 114 |
+
async def handle_unban_command(evt: events.NewMessage.Event) -> None:
|
| 115 |
+
user_id = int(evt.pattern_match.group(1))
|
| 116 |
+
user = await DB.db.get_user(user_id)
|
| 117 |
+
if not user:
|
| 118 |
+
return await evt.reply("User doesn't exist in Database")
|
| 119 |
+
user.ban_date = None
|
| 120 |
+
user.warns = 0
|
| 121 |
+
if await DB.db.upsert_user(user):
|
| 122 |
+
await evt.reply("User has Unbanned and Warns reset to 0")
|
| 123 |
+
await client.send_message(user_id, "You are unbanned now you can use this bot")
|
| 124 |
+
else:
|
| 125 |
+
await evt.reply(f"Unable to unban user {user_id}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@client.on(events.NewMessage(
|
| 129 |
+
incoming=True,
|
| 130 |
+
pattern=r"^/clearwarns (\d+)$",
|
| 131 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 132 |
+
))
|
| 133 |
+
async def handle_clearwarns_command(evt: events.NewMessage.Event) -> None:
|
| 134 |
+
user_id = int(evt.pattern_match.group(1))
|
| 135 |
+
user = await DB.db.get_user(user_id)
|
| 136 |
+
if not user:
|
| 137 |
+
return await evt.reply("User doesn't exist in Database")
|
| 138 |
+
user.warns = 0
|
| 139 |
+
if await DB.db.upsert_user(user):
|
| 140 |
+
await evt.reply("Warns reset to 0")
|
| 141 |
+
await client.send_message(user_id, "Your warns reset to 0")
|
| 142 |
+
else:
|
| 143 |
+
await evt.reply(f"Unable to clear warns for user {user_id}")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@client.on(events.NewMessage(
|
| 147 |
+
incoming=True,
|
| 148 |
+
pattern=r"^/listfiles (\d+|[A-Za-z0-9_\-:/]+)$",
|
| 149 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 150 |
+
))
|
| 151 |
+
async def handle_listfiles_command(evt: events.NewMessage.Event) -> None:
|
| 152 |
+
file_id = evt.pattern_match.group(1)
|
| 153 |
+
if file_id.isdigit():
|
| 154 |
+
file_id = int(file_id)
|
| 155 |
+
else:
|
| 156 |
+
data = parse_token(file_id)
|
| 157 |
+
if not data:
|
| 158 |
+
return await evt.reply("Invalid File Id")
|
| 159 |
+
file_id = data[1]
|
| 160 |
+
users = await DB.db.get_file_users(file_id)
|
| 161 |
+
if not users:
|
| 162 |
+
return await evt.reply("No users have generated a link for this file.")
|
| 163 |
+
reply_text = (
|
| 164 |
+
"These users generated a link for this file:\n"
|
| 165 |
+
+ "\n".join(f"[{uid}](tg://user?id={uid})" for uid in users)
|
| 166 |
+
)
|
| 167 |
+
await evt.reply(reply_text)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@client.on(events.NewMessage(
|
| 171 |
+
incoming=True,
|
| 172 |
+
pattern=r"^/restrictfile (\d+|[A-Za-z0-9_\-:/]+)$",
|
| 173 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 174 |
+
))
|
| 175 |
+
async def handle_restrictfile_command(evt: events.NewMessage.Event) -> None:
|
| 176 |
+
file_id = evt.pattern_match.group(1)
|
| 177 |
+
if file_id.isdigit():
|
| 178 |
+
file_id = int(file_id)
|
| 179 |
+
else:
|
| 180 |
+
data = parse_token(file_id)
|
| 181 |
+
if not data:
|
| 182 |
+
return await evt.reply("Invalid File Id")
|
| 183 |
+
file_id = data[1]
|
| 184 |
+
file = await DB.db.get_file(file_id)
|
| 185 |
+
if not file:
|
| 186 |
+
return await evt.reply("File not found in Database")
|
| 187 |
+
file.is_deleted = True
|
| 188 |
+
await DB.db.update_file_restriction(file.id, file.is_deleted)
|
| 189 |
+
await evt.reply(f"Restricted File with File Id {file.id}")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@client.on(events.NewMessage(
|
| 193 |
+
incoming=True,
|
| 194 |
+
pattern=r"^/deletefile (?:(\d+) (\d+)|([A-Za-z0-9_\-:/]+))$",
|
| 195 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 196 |
+
))
|
| 197 |
+
async def handle_deletefile_command(evt: events.NewMessage.Event) -> None:
|
| 198 |
+
user_id = evt.pattern_match.group(1)
|
| 199 |
+
file_id = evt.pattern_match.group(2)
|
| 200 |
+
token = evt.pattern_match.group(3)
|
| 201 |
+
|
| 202 |
+
if token:
|
| 203 |
+
data = parse_token(token)
|
| 204 |
+
if not data:
|
| 205 |
+
return
|
| 206 |
+
user_id, file_id = data
|
| 207 |
+
else:
|
| 208 |
+
user_id = int(user_id)
|
| 209 |
+
file_id = int(file_id)
|
| 210 |
+
if await DB.db.remove_file(file_id, user_id):
|
| 211 |
+
await evt.reply(f"Deleted file {file_id} from user [{user_id}](tg://user?id={user_id})")
|
| 212 |
+
else:
|
| 213 |
+
await evt.reply(f"Unable to delete file {file_id} associated with user [{user_id}](tg://user?id={user_id})")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@client.on(events.NewMessage(
|
| 217 |
+
incoming=True,
|
| 218 |
+
pattern=r"^/parsetoken ([A-Za-z0-9_\-:/]+)$",
|
| 219 |
+
func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
|
| 220 |
+
))
|
| 221 |
+
async def handle_parsetoken_command(evt: events.NewMessage.Event) -> None:
|
| 222 |
+
token = evt.pattern_match.group(1)
|
| 223 |
+
data = parse_token(token)
|
| 224 |
+
if not data:
|
| 225 |
+
return await evt.reply("Invalid Token")
|
| 226 |
+
user_id, file_id = data
|
| 227 |
+
await evt.reply(f"Token {token} is associated with User ID {user_id} and File ID {file_id}")
|
tgfs/plugins/callback.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from telethon import Button, events
|
| 20 |
+
from telethon.tl.types import InputMediaDocument, InputMediaPhoto
|
| 21 |
+
|
| 22 |
+
from tgfs.config import Config
|
| 23 |
+
from tgfs.telegram import client
|
| 24 |
+
from tgfs.database import DB
|
| 25 |
+
from tgfs.utils.translation import get_lang
|
| 26 |
+
from tgfs.utils.utils import check_get_user, human_bytes, make_token
|
| 27 |
+
|
| 28 |
+
log = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@client.on(events.CallbackQuery(pattern=r"^tos_agree_[1-9]\d{0,19}$"))
|
| 32 |
+
async def handle_tos_button(evt: events.CallbackQuery.Event):
|
| 33 |
+
lang = get_lang()
|
| 34 |
+
callback_data = evt.data.decode('utf-8')
|
| 35 |
+
log.debug("Callback data: %s", callback_data)
|
| 36 |
+
user_id = evt.sender_id
|
| 37 |
+
if not await DB.db.add_user(user_id):
|
| 38 |
+
await evt.answer(lang.SOMETHING_WENT_WRONG)
|
| 39 |
+
return
|
| 40 |
+
# msg = await client.get_messages(user_id, ids=msg_id)
|
| 41 |
+
# await handle_file_message(evt, msg)
|
| 42 |
+
await evt.answer(lang.ACCEPTED_TOS_TEXT)
|
| 43 |
+
await evt.edit(buttons=[[Button.inline(lang.AGREED, b"tos_agreed")]])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_page_(\d+)$"))
|
| 47 |
+
async def handle_list_page(evt: events.CallbackQuery.Event) -> None:
|
| 48 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 49 |
+
lang = get_lang(user)
|
| 50 |
+
kind = evt.pattern_match.group(1).decode()
|
| 51 |
+
page_no = int(evt.pattern_match.group(2))
|
| 52 |
+
is_group = kind == "groupinfo"
|
| 53 |
+
user_id = evt.sender_id
|
| 54 |
+
|
| 55 |
+
total_items = (await DB.db.total_groups(user_id)
|
| 56 |
+
if is_group else await DB.db.total_files(user_id))
|
| 57 |
+
if total_items == 0:
|
| 58 |
+
await evt.edit(lang.NO_LABEL_LINKS_TEXT.format(label=lang.GROUP if is_group else lang.FILE))
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
limit = Config.FILE_INDEX_LIMIT
|
| 62 |
+
total_pages = (total_items + limit - 1) // limit
|
| 63 |
+
|
| 64 |
+
if page_no < 0 or page_no >= total_pages:
|
| 65 |
+
await evt.answer(lang.INVALID_PAGE, alert=True)
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
offset = page_no * limit
|
| 69 |
+
items_gen = (
|
| 70 |
+
DB.db.get_groups(user_id, offset, limit)
|
| 71 |
+
if is_group
|
| 72 |
+
else DB.db.get_files(user_id, offset, limit)
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
buttons: list[list[Button]] = []
|
| 76 |
+
|
| 77 |
+
async for item_id, name in items_gen:
|
| 78 |
+
buttons.append([
|
| 79 |
+
Button.inline(name, data=f"{kind}_file_{item_id}_{page_no}")
|
| 80 |
+
])
|
| 81 |
+
|
| 82 |
+
if not buttons:
|
| 83 |
+
await evt.edit(lang.NO_LABELS_TEXT.format(label=lang.GROUPS if is_group else lang.FILES))
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
nav = []
|
| 87 |
+
if page_no > 0:
|
| 88 |
+
nav.append(Button.inline("<<", f"{kind}_page_{page_no - 1}"))
|
| 89 |
+
|
| 90 |
+
nav.append(Button.inline(
|
| 91 |
+
lang.FILES_BUTTON_CURRENT.format(
|
| 92 |
+
page_no=page_no + 1, total_pages=total_pages),
|
| 93 |
+
b"noop"
|
| 94 |
+
))
|
| 95 |
+
|
| 96 |
+
if page_no + 1 < total_pages:
|
| 97 |
+
nav.append(Button.inline(">>", f"{kind}_page_{page_no + 1}"))
|
| 98 |
+
|
| 99 |
+
buttons.append(nav)
|
| 100 |
+
buttons.append([Button.inline(lang.BACK_TEXT, b"files_menu")])
|
| 101 |
+
|
| 102 |
+
await evt.edit(
|
| 103 |
+
lang.TOTAL_LABEL_COUNT.format(
|
| 104 |
+
total=total_items, label=lang.GROUP if is_group else lang.FILE),
|
| 105 |
+
buttons=buttons
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@client.on(events.CallbackQuery(pattern=r"^fileinfo_file_(\d+)_(\d+)(?:_(\d+))?$"))
|
| 110 |
+
async def handle_fileinfo_button(evt: events.CallbackQuery.Event):
|
| 111 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 112 |
+
lang = get_lang(user)
|
| 113 |
+
file_id = int(evt.pattern_match.group(1))
|
| 114 |
+
page_no = int(evt.pattern_match.group(2))
|
| 115 |
+
group_id = int(evt.pattern_match.group(
|
| 116 |
+
3)) if evt.pattern_match.group(3) else None
|
| 117 |
+
user_id = evt.sender_id
|
| 118 |
+
file_info = await DB.db.get_file(file_id, user_id)
|
| 119 |
+
if file_info is None:
|
| 120 |
+
await evt.answer(lang.FILE_NOT_FOUND_TEXT, alert=True)
|
| 121 |
+
return
|
| 122 |
+
token = make_token(user_id, file_info.id)
|
| 123 |
+
url = f"{Config.PUBLIC_URL}/dl/{token}"
|
| 124 |
+
wt_url = f"{Config.PUBLIC_URL}/wt/{token}"
|
| 125 |
+
await evt.edit(
|
| 126 |
+
lang.FILE_INFO_TEXT.format(
|
| 127 |
+
file_id=file_info.id,
|
| 128 |
+
dc_id=file_info.dc_id,
|
| 129 |
+
file_size=human_bytes(file_info.file_size),
|
| 130 |
+
mime_type=file_info.mime_type,
|
| 131 |
+
file_name=file_info.file_name,
|
| 132 |
+
file_type=lang.PHOTO if file_info.thumb_size else lang.DOCUMENT,
|
| 133 |
+
restricted=lang.YES if file_info.is_deleted else lang.NO
|
| 134 |
+
),
|
| 135 |
+
buttons=[
|
| 136 |
+
[
|
| 137 |
+
Button.url(lang.DOWNLOAD, url),
|
| 138 |
+
Button.url(lang.WATCH, wt_url),
|
| 139 |
+
],
|
| 140 |
+
[
|
| 141 |
+
Button.inline(
|
| 142 |
+
lang.DELETE, (
|
| 143 |
+
f"fileinfo_delconf2_{file_info.id}_{page_no}"
|
| 144 |
+
f"{f'_{group_id}' if group_id else ''}"
|
| 145 |
+
)),
|
| 146 |
+
Button.inline(lang.GET_FILE_TEXT,
|
| 147 |
+
f"fileinfo_get_{file_info.id}")
|
| 148 |
+
],
|
| 149 |
+
[Button.inline(
|
| 150 |
+
lang.BACK_TEXT, f"groupinfo_file_{group_id}_{page_no}" if group_id else f"fileinfo_page_{page_no}")]
|
| 151 |
+
]
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@client.on(events.CallbackQuery(pattern=r"^groupinfo_file_(\d+)_(\d+)$"))
|
| 156 |
+
async def handle_groupinfo_button(evt: events.CallbackQuery.Event):
|
| 157 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 158 |
+
lang = get_lang(user)
|
| 159 |
+
group_id = int(evt.pattern_match.group(1))
|
| 160 |
+
page_no = int(evt.pattern_match.group(2))
|
| 161 |
+
user_id = evt.sender_id
|
| 162 |
+
file_info = await DB.db.get_group(group_id, user_id)
|
| 163 |
+
if file_info is None:
|
| 164 |
+
await evt.answer(lang.GROUP_NOT_FOUND_TEXT, alert=True)
|
| 165 |
+
return
|
| 166 |
+
token = make_token(user_id, file_info.group_id)
|
| 167 |
+
buttons: list[list[Button]] = [
|
| 168 |
+
[Button.url(lang.EXTERNAL_LINK, f"{Config.PUBLIC_URL}/group/{token}")]
|
| 169 |
+
]
|
| 170 |
+
if file_info.files and len(file_info.files) <= 98:
|
| 171 |
+
async for file_id, name in DB.db.get_files2(user_id, file_info.files):
|
| 172 |
+
buttons.append(
|
| 173 |
+
[Button.inline(name, f"fileinfo_file_{file_id}_{page_no}_{group_id}")])
|
| 174 |
+
buttons.append(
|
| 175 |
+
[
|
| 176 |
+
Button.inline(lang.BACK_TEXT, f"groupinfo_page_{page_no}"),
|
| 177 |
+
Button.inline(
|
| 178 |
+
lang.DELETE, f"groupinfo_delconf2_{file_info.group_id}_{page_no}")
|
| 179 |
+
]
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
await evt.edit(
|
| 183 |
+
lang.GROUP_INFO_TEXT.format(
|
| 184 |
+
name=file_info.name,
|
| 185 |
+
created_at=file_info.created_at,
|
| 186 |
+
total_files=len(file_info.files) if file_info.files else 0
|
| 187 |
+
),
|
| 188 |
+
buttons=buttons
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@client.on(events.CallbackQuery(pattern=r"^fileinfo_get_(\d+)$"))
|
| 193 |
+
async def handle_fileinfo_get_button(evt: events.CallbackQuery.Event):
|
| 194 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 195 |
+
lang = get_lang(user)
|
| 196 |
+
file_id = int(evt.pattern_match.group(1))
|
| 197 |
+
user_id = evt.sender_id
|
| 198 |
+
file_info = await DB.db.get_file(file_id, user_id)
|
| 199 |
+
if file_info is None:
|
| 200 |
+
await evt.answer(lang.FILE_NOT_FOUND_TEXT, alert=True)
|
| 201 |
+
return
|
| 202 |
+
location = await DB.db.get_location(file_info, Config.BOT_ID)
|
| 203 |
+
input_media = InputMediaPhoto(
|
| 204 |
+
location) if location.thumb_size else InputMediaDocument(location)
|
| 205 |
+
await client.send_file(user_id, input_media, caption=file_info.file_name)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_delconf2_(\d+)_(\d+)(?:_(\d+))?$"))
|
| 209 |
+
async def handle_fileinfo_del_conf_button(evt: events.CallbackQuery.Event):
|
| 210 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 211 |
+
lang = get_lang(user)
|
| 212 |
+
kind = evt.pattern_match.group(1).decode()
|
| 213 |
+
file_id = int(evt.pattern_match.group(2))
|
| 214 |
+
page_no = int(evt.pattern_match.group(3))
|
| 215 |
+
group_id = int(evt.pattern_match.group(
|
| 216 |
+
4)) if evt.pattern_match.group(4) else None
|
| 217 |
+
user_id = evt.sender_id
|
| 218 |
+
is_group = kind == "groupinfo"
|
| 219 |
+
|
| 220 |
+
file_info = await DB.db.get_group(file_id, user_id) if is_group else await DB.db.get_file(file_id, user_id)
|
| 221 |
+
if file_info is None:
|
| 222 |
+
await evt.answer(lang.GROUP_NOT_FOUND_TEXT if is_group else lang.FILE_NOT_FOUND_TEXT, alert=True)
|
| 223 |
+
return
|
| 224 |
+
await evt.edit(
|
| 225 |
+
lang.CONFIRM_DELETE_TEXT.format(
|
| 226 |
+
label=lang.GROUP if is_group else lang.FILE),
|
| 227 |
+
buttons=[
|
| 228 |
+
[Button.inline(lang.YES+' '+lang.DELETE, (
|
| 229 |
+
f"{kind}_delete_{file_id}_{page_no}"
|
| 230 |
+
f"{f'_{group_id}' if group_id else ''}"
|
| 231 |
+
))],
|
| 232 |
+
[Button.inline(lang.NO, (
|
| 233 |
+
f"{kind}_file_{file_id}_{page_no}"
|
| 234 |
+
f"{f'_{group_id}' if group_id else ''}"
|
| 235 |
+
))]
|
| 236 |
+
]
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_delete_(\d+)_(\d+)(?:_(\d+))?$"))
|
| 241 |
+
async def handle_fileinfo_del_button(evt: events.CallbackQuery.Event):
|
| 242 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 243 |
+
lang = get_lang(user)
|
| 244 |
+
kind = evt.pattern_match.group(1).decode()
|
| 245 |
+
file_id = int(evt.pattern_match.group(2))
|
| 246 |
+
page_no = int(evt.pattern_match.group(3))
|
| 247 |
+
group_id = int(evt.pattern_match.group(
|
| 248 |
+
4)) if evt.pattern_match.group(4) else None
|
| 249 |
+
user_id = evt.sender_id
|
| 250 |
+
is_group = kind == "groupinfo"
|
| 251 |
+
|
| 252 |
+
file_info = await DB.db.get_group(file_id, user_id) if is_group else await DB.db.get_file(file_id, user_id)
|
| 253 |
+
if file_info is None:
|
| 254 |
+
await evt.answer(lang.GROUP_NOT_FOUND_TEXT if is_group else lang.FILE_NOT_FOUND_TEXT, alert=True)
|
| 255 |
+
return
|
| 256 |
+
if is_group:
|
| 257 |
+
await DB.db.delete_group(file_id, user_id)
|
| 258 |
+
else:
|
| 259 |
+
await DB.db.remove_file(file_id, user_id)
|
| 260 |
+
await evt.edit(lang.DELETED_SUCCESSFULLY_TEXT.format(label=lang.GROUP if is_group else lang.FILE), buttons=[
|
| 261 |
+
[
|
| 262 |
+
Button.inline(lang.BACK_TEXT, (
|
| 263 |
+
f"groupinfo_file_{group_id}_{page_no}" if group_id else f"{kind}_page_{page_no}"
|
| 264 |
+
))
|
| 265 |
+
]
|
| 266 |
+
])
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@client.on(events.CallbackQuery(pattern=r"^files_menu$"))
|
| 270 |
+
async def handle_files_menu_button(evt: events.CallbackQuery.Event):
|
| 271 |
+
user = await check_get_user(evt.sender_id, evt.message_id)
|
| 272 |
+
lang = get_lang(user)
|
| 273 |
+
await evt.edit(
|
| 274 |
+
lang.SELECT_TYPE_OF_FILE,
|
| 275 |
+
buttons=[
|
| 276 |
+
[Button.inline(lang.FILES, "fileinfo_page_0")],
|
| 277 |
+
[Button.inline(lang.GROUPS, "groupinfo_page_0")]
|
| 278 |
+
]
|
| 279 |
+
)
|
tgfs/plugins/custom.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
from telethon import events
|
| 21 |
+
from telethon.tl.types import InputMediaDocument, InputMediaPhoto
|
| 22 |
+
|
| 23 |
+
from tgfs.config import Config
|
| 24 |
+
from tgfs.telegram import client
|
| 25 |
+
from tgfs.database import DB
|
| 26 |
+
from tgfs.utils.translation import get_lang
|
| 27 |
+
from tgfs.utils.types import User
|
| 28 |
+
from tgfs.utils.utils import parse_token
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def send_file(_: events.NewMessage.Event, user_id: int, file_id: int) -> None:
|
| 34 |
+
lang = get_lang(user_id)
|
| 35 |
+
file_info = await DB.db.get_file(file_id, user_id)
|
| 36 |
+
if file_info is None:
|
| 37 |
+
return lang.FILE_ID_NOT_FOUND.format(file_id=file_id)
|
| 38 |
+
location = await DB.db.get_location(file_info, Config.BOT_ID)
|
| 39 |
+
input_media = InputMediaPhoto(
|
| 40 |
+
location) if location.thumb_size else InputMediaDocument(location)
|
| 41 |
+
await client.send_file(user_id, input_media, caption=file_info.file_name)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def handle_file_url(evt: events.NewMessage.Event, user: User, match: re.Match) -> None:
|
| 45 |
+
lang = get_lang(user)
|
| 46 |
+
payload = match.group("payload")
|
| 47 |
+
sig = match.group("sig")
|
| 48 |
+
pt = parse_token(payload, sig)
|
| 49 |
+
if not pt:
|
| 50 |
+
await evt.reply(lang.INAVLID_LINK_TEXT)
|
| 51 |
+
return
|
| 52 |
+
user_id, file_id = pt
|
| 53 |
+
status = await send_file(evt, user_id, file_id)
|
| 54 |
+
if status:
|
| 55 |
+
await evt.reply(status)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def handle_group_url(evt: events.NewMessage.Event, user: User, match: re.Match) -> None:
|
| 59 |
+
lang = get_lang(user)
|
| 60 |
+
payload = match.group("payload")
|
| 61 |
+
sig = match.group("sig")
|
| 62 |
+
pt = parse_token(payload, sig)
|
| 63 |
+
if not pt:
|
| 64 |
+
await evt.reply(lang.INAVLID_LINK_TEXT)
|
| 65 |
+
return
|
| 66 |
+
user_id, group_id = pt
|
| 67 |
+
group = await DB.db.get_group(group_id, user_id)
|
| 68 |
+
for file_id in group.files:
|
| 69 |
+
status = await send_file(evt, user_id, file_id)
|
| 70 |
+
if status:
|
| 71 |
+
await evt.reply(status)
|
| 72 |
+
await evt.reply(lang.GROUP_ENDOF_FILES)
|
| 73 |
+
|
| 74 |
+
# pylint: disable=C0301
|
| 75 |
+
HANDLERS = [
|
| 76 |
+
(re.compile(
|
| 77 |
+
r"^(?P<scheme>https?):\/\/(?P<host>(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::(?P<port>\d{1,5}))?\/dl\/(?P<payload>[^\/]+)\/(?P<sig>[^\/]+)$"), handle_file_url),
|
| 78 |
+
(re.compile(
|
| 79 |
+
r"^(?P<scheme>https?):\/\/(?P<host>(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::(?P<port>\d{1,5}))?\/group\/(?P<payload>[^\/]+)\/(?P<sig>[^\/]+)$"), handle_group_url)
|
| 80 |
+
]
|
tgfs/plugins/files.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from typing import cast
|
| 19 |
+
|
| 20 |
+
from telethon import events, Button
|
| 21 |
+
from telethon.custom import Message
|
| 22 |
+
from telethon.utils import get_input_location
|
| 23 |
+
from telethon.errors import ButtonUrlInvalidError
|
| 24 |
+
|
| 25 |
+
from tgfs.config import Config
|
| 26 |
+
from tgfs.database import DB
|
| 27 |
+
from tgfs.telegram import client, multi_clients
|
| 28 |
+
from tgfs.utils.translation import get_lang
|
| 29 |
+
from tgfs.utils.utils import check_get_user, make_token
|
| 30 |
+
from tgfs.utils.types import FileInfo, FileSource, InputTypeLocation, Status
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
log = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@client.on(events.NewMessage(incoming=True, func=lambda x: x.is_private and x.file))
|
| 37 |
+
async def handle_file_message(evt: events.NewMessage.Event, msg=None) -> None:
|
| 38 |
+
msg: Message = evt.message if not msg else msg
|
| 39 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 40 |
+
if user is None:
|
| 41 |
+
return
|
| 42 |
+
if user.curt_op in [Status.GROUP_NAME, Status.GROUP]:
|
| 43 |
+
return
|
| 44 |
+
lang = get_lang(user)
|
| 45 |
+
dc_id, location = cast(
|
| 46 |
+
tuple[int, InputTypeLocation], get_input_location(msg.media))
|
| 47 |
+
file_info = FileInfo(
|
| 48 |
+
id=location.id,
|
| 49 |
+
dc_id=dc_id,
|
| 50 |
+
file_size=msg.file.size,
|
| 51 |
+
mime_type=msg.file.mime_type,
|
| 52 |
+
file_name=msg.file.name or f"{location.id}{msg.file.ext or ''}",
|
| 53 |
+
thumb_size=location.thumb_size,
|
| 54 |
+
is_deleted=False
|
| 55 |
+
)
|
| 56 |
+
file_source = FileSource(
|
| 57 |
+
chat_id=msg.chat_id,
|
| 58 |
+
message_id=msg.id,
|
| 59 |
+
)
|
| 60 |
+
await DB.db.add_file(user.user_id, file_info, file_source)
|
| 61 |
+
await DB.db.upsert_location(
|
| 62 |
+
multi_clients[0].client_id,
|
| 63 |
+
location
|
| 64 |
+
)
|
| 65 |
+
# fwd_msg: Message = await msg.forward_to(Config.BIN_CHANNEL)
|
| 66 |
+
token = make_token(msg.sender_id, file_info.id)
|
| 67 |
+
url = f"{Config.PUBLIC_URL}/dl/{token}"
|
| 68 |
+
wt_url = f"{Config.PUBLIC_URL}/wt/{token}"
|
| 69 |
+
try:
|
| 70 |
+
await evt.reply(
|
| 71 |
+
url,
|
| 72 |
+
buttons=[
|
| 73 |
+
[
|
| 74 |
+
Button.url(lang.DOWNLOAD, url),
|
| 75 |
+
Button.url(lang.WATCH, wt_url)
|
| 76 |
+
],
|
| 77 |
+
]
|
| 78 |
+
)
|
| 79 |
+
except ButtonUrlInvalidError as e:
|
| 80 |
+
log.error("Failed to send download link: %s", e)
|
| 81 |
+
await evt.reply(url)
|
| 82 |
+
log.info("Generated Link %s", url)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/group", func=lambda x: x.is_private and not x.file))
|
| 86 |
+
async def handle_group_command(evt: events.NewMessage.Event) -> None:
|
| 87 |
+
msg: Message = evt.message
|
| 88 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 89 |
+
if user is None:
|
| 90 |
+
return
|
| 91 |
+
lang = get_lang(user)
|
| 92 |
+
if user.curt_op == Status.NO_OP:
|
| 93 |
+
user.curt_op = Status.GROUP
|
| 94 |
+
user.op_id = msg.id
|
| 95 |
+
await DB.db.upsert_user(user)
|
| 96 |
+
await evt.reply(lang.GROUP_SENDFILE_TEXT)
|
| 97 |
+
else:
|
| 98 |
+
await evt.reply(lang.ALREADY_IN_OP)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/done", func=lambda x: x.is_private and not x.file))
|
| 102 |
+
async def handle_done_command(evt: events.NewMessage.Event, user=None) -> None:
|
| 103 |
+
msg: Message = evt.message
|
| 104 |
+
user = user or await check_get_user(msg.sender_id, msg.id)
|
| 105 |
+
if user is None:
|
| 106 |
+
return
|
| 107 |
+
lang = get_lang(user)
|
| 108 |
+
if user.curt_op == Status.NO_OP:
|
| 109 |
+
await evt.reply(lang.NOT_IN_OP)
|
| 110 |
+
elif user.curt_op == Status.GROUP:
|
| 111 |
+
min_id = user.op_id+1
|
| 112 |
+
max_id = msg.id
|
| 113 |
+
order = 0
|
| 114 |
+
group_id = await DB.db.create_group(user.user_id, str(msg.id))
|
| 115 |
+
try:
|
| 116 |
+
file_msgs: list[Message] = await client.get_messages(
|
| 117 |
+
entity=msg.chat_id,
|
| 118 |
+
ids=range(min_id, max_id),
|
| 119 |
+
)
|
| 120 |
+
file_msgs = list(filter(lambda m: m and m.file, file_msgs))
|
| 121 |
+
if not file_msgs:
|
| 122 |
+
await DB.db.delete_group(group_id, user.user_id)
|
| 123 |
+
await evt.reply(lang.GROUP_NOFILES_TEXT)
|
| 124 |
+
user.curt_op = Status.NO_OP
|
| 125 |
+
user.op_id = 0
|
| 126 |
+
await DB.db.upsert_user(user)
|
| 127 |
+
return
|
| 128 |
+
for file_msg in file_msgs:
|
| 129 |
+
dc_id, location = cast(
|
| 130 |
+
tuple[int, InputTypeLocation], get_input_location(file_msg.media))
|
| 131 |
+
file_info = FileInfo(
|
| 132 |
+
id=location.id,
|
| 133 |
+
dc_id=dc_id,
|
| 134 |
+
file_size=file_msg.file.size,
|
| 135 |
+
mime_type=file_msg.file.mime_type,
|
| 136 |
+
file_name=file_msg.file.name or f"{location.id}{file_msg.file.ext or ''}",
|
| 137 |
+
thumb_size=location.thumb_size,
|
| 138 |
+
is_deleted=False
|
| 139 |
+
)
|
| 140 |
+
file_source = FileSource(
|
| 141 |
+
chat_id=file_msg.chat_id,
|
| 142 |
+
message_id=file_msg.id
|
| 143 |
+
)
|
| 144 |
+
await DB.db.add_file(user.user_id, file_info, file_source)
|
| 145 |
+
await DB.db.upsert_location(
|
| 146 |
+
multi_clients[0].client_id,
|
| 147 |
+
location
|
| 148 |
+
)
|
| 149 |
+
order += 1
|
| 150 |
+
await DB.db.add_file_to_group(group_id, user.user_id, file_info.id, order)
|
| 151 |
+
await evt.reply(lang.GROUP_NAME_TEXT)
|
| 152 |
+
user.curt_op = Status.GROUP_NAME
|
| 153 |
+
user.op_id = group_id
|
| 154 |
+
await DB.db.upsert_user(user)
|
| 155 |
+
except Exception as e: # pylint: disable=W0718
|
| 156 |
+
DB.db.delete_group(group_id, user.user_id)
|
| 157 |
+
log.error(e, exc_info=True, stack_info=True)
|
| 158 |
+
else:
|
| 159 |
+
await evt.reply(lang.UNKNOWN_COMMAND)
|
| 160 |
+
|
| 161 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/files", func=lambda x: x.is_private and not x.file))
|
| 162 |
+
async def handle_myfiles_command(evt: events.NewMessage.Event) -> None:
|
| 163 |
+
msg: Message = evt.message
|
| 164 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 165 |
+
if user is None:
|
| 166 |
+
return
|
| 167 |
+
lang = get_lang(user)
|
| 168 |
+
total_files = await DB.db.total_files(user.user_id)
|
| 169 |
+
total_groups = await DB.db.total_groups(user.user_id)
|
| 170 |
+
await evt.reply(lang.FILES_TEXT.format(total_files=total_files, total_groups=total_groups),
|
| 171 |
+
buttons=[
|
| 172 |
+
[Button.inline(lang.FILES, "fileinfo_page_0")],
|
| 173 |
+
[Button.inline(lang.GROUPS, "groupinfo_page_0")]
|
| 174 |
+
]
|
| 175 |
+
)
|
tgfs/plugins/message.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from telethon import events
|
| 20 |
+
from telethon.custom import Message
|
| 21 |
+
|
| 22 |
+
from tgfs.config import Config
|
| 23 |
+
from tgfs.plugins.custom import HANDLERS
|
| 24 |
+
from tgfs.telegram import client
|
| 25 |
+
from tgfs.database import DB
|
| 26 |
+
from tgfs.utils.translation import get_lang, registry
|
| 27 |
+
from tgfs.utils.types import Status, User
|
| 28 |
+
from tgfs.utils.utils import check_get_user, make_token
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/start", func=lambda x: x.is_private and not x.file))
|
| 34 |
+
async def handle_start_command(evt: events.NewMessage.Event) -> None:
|
| 35 |
+
msg: Message = evt.message
|
| 36 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 37 |
+
if not user:
|
| 38 |
+
return
|
| 39 |
+
lang = get_lang(user)
|
| 40 |
+
await evt.reply(lang.START_TEXT)
|
| 41 |
+
|
| 42 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/help", func=lambda x: x.is_private and not x.file))
|
| 43 |
+
async def handle_help_command(evt: events.NewMessage.Event) -> None:
|
| 44 |
+
msg: Message = evt.message
|
| 45 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 46 |
+
if not user:
|
| 47 |
+
return
|
| 48 |
+
lang = get_lang(user)
|
| 49 |
+
await evt.reply(lang.HELP_TEXT)
|
| 50 |
+
|
| 51 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^(?!/).*", func=lambda x: x.is_private and not x.file))
|
| 52 |
+
async def handle_text_message(evt: events.NewMessage.Event) -> None:
|
| 53 |
+
msg: Message = evt.message
|
| 54 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 55 |
+
if user is None:
|
| 56 |
+
return
|
| 57 |
+
lang = get_lang(user)
|
| 58 |
+
|
| 59 |
+
message = msg.message.strip()
|
| 60 |
+
if user.curt_op == Status.GROUP_NAME:
|
| 61 |
+
await handle_group_name(evt, user)
|
| 62 |
+
else:
|
| 63 |
+
for pattern, func in HANDLERS:
|
| 64 |
+
m = pattern.match(message)
|
| 65 |
+
if m:
|
| 66 |
+
await func(evt, user, m)
|
| 67 |
+
return
|
| 68 |
+
|
| 69 |
+
await evt.reply(lang.UNKNOWN_COMMAND)
|
| 70 |
+
|
| 71 |
+
async def handle_group_name(evt: events.NewMessage.Event, user: User) -> None:
|
| 72 |
+
lang = get_lang(user)
|
| 73 |
+
msg: Message = evt.message
|
| 74 |
+
name = msg.text.strip()
|
| 75 |
+
group_id = user.op_id
|
| 76 |
+
await DB.db.update_group_name(group_id, user.user_id, name)
|
| 77 |
+
user.curt_op = Status.NO_OP
|
| 78 |
+
user.op_id = 0
|
| 79 |
+
await DB.db.upsert_user(user)
|
| 80 |
+
token = make_token(user.user_id, group_id)
|
| 81 |
+
url = f"{Config.PUBLIC_URL}/group/{token}"
|
| 82 |
+
await evt.reply(lang.GROUP_CREATED_TEXT.format(name=name, url=url))
|
| 83 |
+
|
| 84 |
+
@client.on(events.NewMessage(
|
| 85 |
+
incoming=True,
|
| 86 |
+
pattern=r"^/setln(?:\s+([a-z]{2}))?$",
|
| 87 |
+
func=lambda x: x.is_private and not x.file
|
| 88 |
+
))
|
| 89 |
+
async def handle_setln_command(evt: events.NewMessage.Event) -> None:
|
| 90 |
+
msg: Message = evt.message
|
| 91 |
+
user = await check_get_user(msg.sender_id, msg.id)
|
| 92 |
+
if user is None:
|
| 93 |
+
return
|
| 94 |
+
lang = get_lang(user)
|
| 95 |
+
ln_code = evt.pattern_match.group(1)
|
| 96 |
+
if ln_code is None or ln_code not in registry:
|
| 97 |
+
await evt.reply(lang.SETLN_USAGE_TEXT.format(supported_codes=", ".join(registry.keys())))
|
| 98 |
+
return
|
| 99 |
+
user.preferred_lang = ln_code
|
| 100 |
+
if await DB.db.upsert_user(user):
|
| 101 |
+
await evt.reply(lang.SETLN_SET_TO.format(ln_code=ln_code))
|
| 102 |
+
else:
|
| 103 |
+
await evt.reply(lang.SOMETHING_WENT_WRONG)
|
| 104 |
+
|
| 105 |
+
@client.on(events.NewMessage(incoming=True, pattern=r"^/cancel", func=lambda x: x.is_private and not x.file))
|
| 106 |
+
async def handle_cancel_command(evt: events.NewMessage.Event, user=None) -> None:
|
| 107 |
+
msg: Message = evt.message
|
| 108 |
+
user = user or await check_get_user(msg.sender_id, msg.id)
|
| 109 |
+
if user is None:
|
| 110 |
+
return
|
| 111 |
+
lang = get_lang(user)
|
| 112 |
+
user.curt_op=Status.NO_OP
|
| 113 |
+
user.op_id=0
|
| 114 |
+
await DB.db.upsert_user(user)
|
| 115 |
+
await evt.reply(lang.OPERATION_CANCELED)
|
tgfs/routes.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import asyncio
|
| 19 |
+
from aiohttp import web
|
| 20 |
+
|
| 21 |
+
from tgfs.config import Config
|
| 22 |
+
from tgfs.paralleltransfer import ParallelTransferrer
|
| 23 |
+
from tgfs.utils.utils import make_token, parse_token, update_location, uptime_human
|
| 24 |
+
from tgfs.telegram import multi_clients
|
| 25 |
+
from tgfs.database import DB
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger(__name__)
|
| 28 |
+
routes = web.RouteTableDef()
|
| 29 |
+
|
| 30 |
+
client_selection_lock = asyncio.Lock()
|
| 31 |
+
|
| 32 |
+
@routes.get("/")
|
| 33 |
+
async def handle_root(_: web.Request):
|
| 34 |
+
return web.json_response({
|
| 35 |
+
'uptime': uptime_human(),
|
| 36 |
+
'load': {transfer.client_id: transfer.users for transfer in multi_clients}
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
# @routes.get(r"/{msg_id:-?\d+}/{name}")
|
| 40 |
+
@routes.get("/dl/{payload}/{sig}")
|
| 41 |
+
@routes.get("/wt/{payload}/{sig}")
|
| 42 |
+
async def handle_file_request(req: web.Request, head: bool = None, watch: bool = None) -> web.Response:
|
| 43 |
+
if watch is None:
|
| 44 |
+
watch: bool = True if "wt" in req.path.split("/") else False
|
| 45 |
+
if head is None:
|
| 46 |
+
head: bool = req.method == "HEAD"
|
| 47 |
+
payload = req.match_info["payload"]
|
| 48 |
+
sig = req.match_info["sig"]
|
| 49 |
+
pt = parse_token(payload, sig)
|
| 50 |
+
if not pt:
|
| 51 |
+
return web.Response(status=404, text="File not found")
|
| 52 |
+
user_id, file_id = pt
|
| 53 |
+
|
| 54 |
+
file = await DB.db.get_file(file_id, user_id)
|
| 55 |
+
if not file:
|
| 56 |
+
return web.Response(status=404, text="File not found")
|
| 57 |
+
if file.is_deleted:
|
| 58 |
+
return web.Response(status=451, text="File is restricted")
|
| 59 |
+
|
| 60 |
+
size = file.file_size
|
| 61 |
+
from_bytes = req.http_range.start or 0
|
| 62 |
+
until_bytes = (req.http_range.stop or size) - 1
|
| 63 |
+
|
| 64 |
+
if (until_bytes >= size) or (from_bytes < 0) or (until_bytes < from_bytes):
|
| 65 |
+
return web.Response(status=416, headers={"Content-Range": f"bytes */{size}"})
|
| 66 |
+
if head:
|
| 67 |
+
body=None
|
| 68 |
+
else:
|
| 69 |
+
transfer: ParallelTransferrer = min(multi_clients, key=lambda c: c.users)
|
| 70 |
+
log.debug("Using client %s", transfer.client_id)
|
| 71 |
+
location = await DB.db.get_location(file, transfer.client_id)
|
| 72 |
+
if location is None:
|
| 73 |
+
source = await DB.db.get_source(file.id, user_id)
|
| 74 |
+
location = await update_location(source, transfer)
|
| 75 |
+
body=transfer.download(location, file.dc_id, size, from_bytes, until_bytes)
|
| 76 |
+
|
| 77 |
+
return web.Response(
|
| 78 |
+
status=200 if (from_bytes == 0 and until_bytes == size - 1) else 206,
|
| 79 |
+
body=body,
|
| 80 |
+
headers={
|
| 81 |
+
"Content-Type": file.mime_type,
|
| 82 |
+
"Content-Range": f"bytes {from_bytes}-{until_bytes}/{size}",
|
| 83 |
+
"Content-Length": str(until_bytes - from_bytes + 1),
|
| 84 |
+
"Content-Disposition": f'{"inline" if watch else "attachment"}; filename="{file.file_name}"',
|
| 85 |
+
"Accept-Ranges": "bytes",
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
@routes.get("/group/{payload}/{sig}")
|
| 89 |
+
async def handle_group_request(req: web.Request) -> web.Response:
|
| 90 |
+
payload = req.match_info["payload"]
|
| 91 |
+
sig = req.match_info["sig"]
|
| 92 |
+
pt = parse_token(payload, sig)
|
| 93 |
+
if not pt:
|
| 94 |
+
return web.Response(status=404, text="File not found")
|
| 95 |
+
user_id, group_id = pt
|
| 96 |
+
group = await DB.db.get_group(group_id, user_id)
|
| 97 |
+
if group is None:
|
| 98 |
+
return web.Response(status=404, text="Group not found")
|
| 99 |
+
resp = "".join(f"{Config.PUBLIC_URL}/dl/{make_token(user_id, file_id)}\n" for file_id in group.files)
|
| 100 |
+
return web.Response(status=200, text=resp)
|
| 101 |
+
|
| 102 |
+
@routes.get("/dl/{object_id}", allow_head=True)
|
| 103 |
+
async def stream_handler(request: web.Request):
|
| 104 |
+
object_id = request.match_info["object_id"]
|
| 105 |
+
file = await DB.db.get_file_old(object_id)
|
| 106 |
+
if not file:
|
| 107 |
+
return web.Response(status=404, text="File not found")
|
| 108 |
+
token = make_token(file["user_id"], file["media_id"])
|
| 109 |
+
return web.HTTPFound(f"{Config.PUBLIC_URL}/dl/{token}")
|
tgfs/telegram.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import importlib.util
|
| 19 |
+
import logging
|
| 20 |
+
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
import sys
|
| 23 |
+
from typing import Optional
|
| 24 |
+
from telethon import TelegramClient, functions
|
| 25 |
+
from telethon.sessions import MemorySession
|
| 26 |
+
from telethon.tl.types import InputPeerUser
|
| 27 |
+
|
| 28 |
+
from tgfs.config import Config
|
| 29 |
+
from tgfs.paralleltransfer import ParallelTransferrer
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
multi_clients: list[ParallelTransferrer] = []
|
| 34 |
+
|
| 35 |
+
client = TelegramClient(
|
| 36 |
+
"tg-filestream"+Config.SESSION_NAME,
|
| 37 |
+
api_id=Config.API_ID,
|
| 38 |
+
api_hash=Config.API_HASH,
|
| 39 |
+
receive_updates=not Config.NO_UPDATE,
|
| 40 |
+
sequential_updates=Config.SEQUENTIAL_UPDATES
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
async def _start_client(token: str) -> Optional[ParallelTransferrer]:
|
| 44 |
+
bot = TelegramClient(
|
| 45 |
+
MemorySession(),
|
| 46 |
+
api_id=Config.API_ID,
|
| 47 |
+
api_hash=Config.API_HASH,
|
| 48 |
+
receive_updates=False
|
| 49 |
+
)
|
| 50 |
+
try:
|
| 51 |
+
await bot.start(bot_token=token)
|
| 52 |
+
# https://github.com/LonamiWebs/Telethon/blob/59da66e105ba29eee7760538409181859c7d310d/telethon/client/downloads.py#L62
|
| 53 |
+
config = await bot(functions.help.GetConfigRequest())
|
| 54 |
+
for option in config.dc_options:
|
| 55 |
+
if option.ip_address == bot.session.server_address:
|
| 56 |
+
bot.session.set_dc(
|
| 57 |
+
option.id, option.ip_address, option.port)
|
| 58 |
+
bot.session.save()
|
| 59 |
+
break
|
| 60 |
+
me: InputPeerUser = await bot.get_me(True)
|
| 61 |
+
transfer = ParallelTransferrer(bot, me.user_id)
|
| 62 |
+
transfer.post_init()
|
| 63 |
+
return transfer
|
| 64 |
+
except Exception as e: # pylint: disable=W0718
|
| 65 |
+
log.error("Faied to Start Client %s: %s", token.split(":")[0], e)
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
async def start_clients():
|
| 69 |
+
tasks = (_start_client(t) for t in Config.TOKENS)
|
| 70 |
+
results = await asyncio.gather(*tasks)
|
| 71 |
+
multi_clients.extend(filter(None, results))
|
| 72 |
+
|
| 73 |
+
def load_plugins(folder_path: str) -> None:
|
| 74 |
+
folder = Path(folder_path)
|
| 75 |
+
package_prefix = ".".join(folder.parts)
|
| 76 |
+
for file in folder.glob("*.py"):
|
| 77 |
+
module_name = f"{package_prefix}.{file.stem}"
|
| 78 |
+
if module_name in sys.modules:
|
| 79 |
+
log.debug("Already Imported %s, skipping", module_name)
|
| 80 |
+
continue
|
| 81 |
+
spec = importlib.util.spec_from_file_location(module_name, str(file))
|
| 82 |
+
module = importlib.util.module_from_spec(spec)
|
| 83 |
+
sys.modules[module_name] = module
|
| 84 |
+
module.__package__ = package_prefix
|
| 85 |
+
spec.loader.exec_module(module)
|
| 86 |
+
log.info("Imported %s", module_name)
|
tgfs/utils/cache_util.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import logging
|
| 19 |
+
|
| 20 |
+
from collections import OrderedDict
|
| 21 |
+
from typing import Optional, Callable, Awaitable, Any
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
class AsyncLRUCache:
|
| 26 |
+
def __init__(self, fn: Callable[..., Awaitable[Any]], maxsize: Optional[int], use_first_arg: bool) -> None:
|
| 27 |
+
self.fn = fn
|
| 28 |
+
self.maxsize = maxsize
|
| 29 |
+
self.use_first_arg = use_first_arg
|
| 30 |
+
self.cache: OrderedDict[int, asyncio.Task] = OrderedDict()
|
| 31 |
+
|
| 32 |
+
def _make_key(self, args, kwargs) -> int:
|
| 33 |
+
if self.use_first_arg:
|
| 34 |
+
if not args:
|
| 35 |
+
raise ValueError("First argument missing for use_first_arg=True")
|
| 36 |
+
return hash(args[0])
|
| 37 |
+
else:
|
| 38 |
+
key_data = args
|
| 39 |
+
if kwargs:
|
| 40 |
+
key_data += tuple(sorted(kwargs.items()))
|
| 41 |
+
return hash(key_data)
|
| 42 |
+
|
| 43 |
+
async def __call__(self, *args, **kwargs) -> Any:
|
| 44 |
+
key = self._make_key(args, kwargs)
|
| 45 |
+
|
| 46 |
+
if key in self.cache:
|
| 47 |
+
task = self.cache[key]
|
| 48 |
+
self.cache.move_to_end(key)
|
| 49 |
+
return await asyncio.shield(task)
|
| 50 |
+
|
| 51 |
+
async def call() -> Any:
|
| 52 |
+
try:
|
| 53 |
+
return await self.fn(*args, **kwargs)
|
| 54 |
+
except Exception:
|
| 55 |
+
self.cache.pop(key, None)
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
task = asyncio.create_task(call())
|
| 59 |
+
self.cache[key] = task
|
| 60 |
+
|
| 61 |
+
if self.maxsize is not None and len(self.cache) > self.maxsize:
|
| 62 |
+
self.cache.popitem(last=False)
|
| 63 |
+
|
| 64 |
+
result = await asyncio.shield(task)
|
| 65 |
+
if result is None:
|
| 66 |
+
self.cache.pop(key, None)
|
| 67 |
+
return result
|
| 68 |
+
|
| 69 |
+
def cache_clear(self) -> None:
|
| 70 |
+
self.cache.clear()
|
| 71 |
+
|
| 72 |
+
def lru_cache(maxsize: Optional[int] = 128, use_first_arg: bool = False
|
| 73 |
+
) -> Callable[[Callable[..., Awaitable[Any]]], AsyncLRUCache]:
|
| 74 |
+
def decorator(fn: Callable[..., Awaitable[Any]]) -> AsyncLRUCache:
|
| 75 |
+
return AsyncLRUCache(fn, maxsize, use_first_arg)
|
| 76 |
+
return decorator
|
tgfs/utils/config_utils.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from os import environ
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
class ConfigBase:
|
| 24 |
+
@staticmethod
|
| 25 |
+
def env_bool(key: str, default: bool = False) -> bool:
|
| 26 |
+
val = environ.get(key)
|
| 27 |
+
if val is None:
|
| 28 |
+
return default
|
| 29 |
+
return val.lower() in ("1", "true", "yes", "on")
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def env_int(key: str, default: int) -> int:
|
| 33 |
+
return int(environ.get(key, default))
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
def get_multi_client_tokens() -> list[str]:
|
| 37 |
+
prefix = "MULTI_TOKEN"
|
| 38 |
+
tokens: list[tuple[int, str]] = []
|
| 39 |
+
|
| 40 |
+
for key, value in environ.items():
|
| 41 |
+
if key.startswith(prefix):
|
| 42 |
+
suffix = key[len(prefix):]
|
| 43 |
+
if suffix.isdigit():
|
| 44 |
+
tokens.append((int(suffix), value))
|
| 45 |
+
|
| 46 |
+
tokens.sort(key=lambda x: x[0])
|
| 47 |
+
return [token for _, token in tokens]
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
def load_backend_config(
|
| 51 |
+
prefix: str,
|
| 52 |
+
schema: dict[str, tuple[type, Any]],
|
| 53 |
+
required: set[str],
|
| 54 |
+
) -> dict[str, Any]:
|
| 55 |
+
kwargs: dict[str, Any] = {}
|
| 56 |
+
missing: list[str] = []
|
| 57 |
+
|
| 58 |
+
for key, (typ, default) in schema.items():
|
| 59 |
+
env_key = f"{prefix}_{key.upper()}"
|
| 60 |
+
|
| 61 |
+
if env_key in environ:
|
| 62 |
+
kwargs[key] = typ(environ[env_key])
|
| 63 |
+
elif default is not None:
|
| 64 |
+
kwargs[key] = default
|
| 65 |
+
else:
|
| 66 |
+
missing.append(env_key)
|
| 67 |
+
|
| 68 |
+
if set(missing) & required:
|
| 69 |
+
raise RuntimeError(
|
| 70 |
+
f"Missing required environment variables: {', '.join(missing)}"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return kwargs
|
tgfs/utils/patches.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
from typing import Callable, Any
|
| 4 |
+
from enum import Enum
|
| 5 |
+
|
| 6 |
+
log = logging.getLogger("events")
|
| 7 |
+
|
| 8 |
+
class Events(Enum):
|
| 9 |
+
STARTUP = "startup"
|
| 10 |
+
AFTER_WEBAPP = "after_webapp"
|
| 11 |
+
REQUEST = "request"
|
| 12 |
+
|
| 13 |
+
HANDLER=dict[Events, list[Callable[..., Any]]]
|
| 14 |
+
|
| 15 |
+
_register_blocking: HANDLER = defaultdict(list)
|
| 16 |
+
_register_background: HANDLER = defaultdict(list)
|
| 17 |
+
_hook_blocking: HANDLER = defaultdict(list)
|
| 18 |
+
_hook_background: HANDLER = defaultdict(list)
|
tgfs/utils/translation.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
# pylint: disable=C0103
|
| 18 |
+
|
| 19 |
+
from typing import Union
|
| 20 |
+
|
| 21 |
+
from tgfs.utils.types import User
|
| 22 |
+
|
| 23 |
+
class en:
|
| 24 |
+
# ── Start & Help ─────────────────────────────────────────────
|
| 25 |
+
START_TEXT = """Send me any telegram file or photo I will generate a link for it
|
| 26 |
+
Use /help to see available commands."""
|
| 27 |
+
|
| 28 |
+
HELP_TEXT = """
|
| 29 |
+
Available Commands:
|
| 30 |
+
/start - Start the bot
|
| 31 |
+
/help - Show this help message
|
| 32 |
+
/group - Start creating a group of files
|
| 33 |
+
/done - Finish adding files to the group
|
| 34 |
+
/files - List your uploaded files or created groups
|
| 35 |
+
/setln - Change your language
|
| 36 |
+
/cancel - Cancel the current operation
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
ACCEPTED_TOS_TEXT = "You have agreed to the Terms of Service."
|
| 40 |
+
|
| 41 |
+
# ── Errors & State ────────────────────────────────────────────
|
| 42 |
+
UNKNOWN_COMMAND = "Unknown command/operation"
|
| 43 |
+
SOMETHING_WENT_WRONG = "Something went wrong. Please try again later."
|
| 44 |
+
INAVLID_LINK_TEXT = "Invalid link."
|
| 45 |
+
INVALID_PAGE = "Invalid page."
|
| 46 |
+
|
| 47 |
+
ALREADY_IN_OP = "You are already in an operation. Please complete it before starting a new one."
|
| 48 |
+
NOT_IN_OP = "You are not in any operation."
|
| 49 |
+
|
| 50 |
+
FILE_ID_NOT_FOUND = "File with id `{file_id}` not found."
|
| 51 |
+
FILE_LOCATION_NOT_FOUND = "File location not found."
|
| 52 |
+
FILE_NOT_FOUND_TEXT = "File not found."
|
| 53 |
+
GROUP_NOT_FOUND_TEXT = "Group not found."
|
| 54 |
+
OPERATION_CANCELED = "Current operation has been canceled"
|
| 55 |
+
|
| 56 |
+
# ── File & Group Info ─────────────────────────────────────────
|
| 57 |
+
FILES_TEXT = """You have created links for:
|
| 58 |
+
• Files: {total_files}
|
| 59 |
+
• Groups: {total_groups}
|
| 60 |
+
|
| 61 |
+
Select the type of links you want to view.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
FILE_INFO_TEXT = """File Info:
|
| 65 |
+
ID: {file_id}
|
| 66 |
+
DC ID: {dc_id}
|
| 67 |
+
Size: {file_size}
|
| 68 |
+
MIME Type: {mime_type}
|
| 69 |
+
File Name: {file_name}
|
| 70 |
+
File Type: {file_type}
|
| 71 |
+
Is Restricted: {restricted}"""
|
| 72 |
+
|
| 73 |
+
GROUP_INFO_TEXT = """Group Info:
|
| 74 |
+
Name: {name}
|
| 75 |
+
Created At: {created_at}
|
| 76 |
+
Total Files: {total_files}"""
|
| 77 |
+
|
| 78 |
+
# ── Group Flow ────────────────────────────────────────────────
|
| 79 |
+
GROUP_NAME_TEXT = "Send a name for your group of files"
|
| 80 |
+
GROUP_SENDFILE_TEXT = "Send files to add to the group. When done, send /done"
|
| 81 |
+
GROUP_CREATED_TEXT = "Group '{name}' created!\n{url}"
|
| 82 |
+
GROUP_NOFILES_TEXT = "No files were added to the group. Operation cancelled."
|
| 83 |
+
GROUP_ENDOF_FILES = "End of group files."
|
| 84 |
+
|
| 85 |
+
# ── File Actions ──────────────────────────────────────────────
|
| 86 |
+
CONFIRM_DELETE_TEXT = "Do you really want to delete this {label}?"
|
| 87 |
+
DELETED_SUCCESSFULLY_TEXT = "{label} deleted successfully."
|
| 88 |
+
SELECT_TYPE_OF_FILE = "Select the type of links you want to view."
|
| 89 |
+
GET_FILE_TEXT = "Get File"
|
| 90 |
+
|
| 91 |
+
# ── Lists & Pagination ────────────────────────────────────────
|
| 92 |
+
NO_LABEL_LINKS_TEXT = "You have not generated any {label} links yet."
|
| 93 |
+
NO_LABELS_TEXT = "No {label}s on this page."
|
| 94 |
+
TOTAL_LABEL_COUNT = "You have **{total}** {label}s."
|
| 95 |
+
FILES_BUTTON_CURRENT = "Page {page_no}/{total_pages}"
|
| 96 |
+
|
| 97 |
+
# ── Languages ─────────────────────────────────────────────────
|
| 98 |
+
SETLN_USAGE_TEXT = "/setln <language_code>\nExample: /setln en\nSupported language codes: {supported_codes}"
|
| 99 |
+
SETLN_SET_TO = "Language changed to {ln_code}"
|
| 100 |
+
|
| 101 |
+
# ── Buttons & Labels ──────────────────────────────────────────
|
| 102 |
+
YES = "Yes"
|
| 103 |
+
NO = "No"
|
| 104 |
+
BACK_TEXT = "Back"
|
| 105 |
+
|
| 106 |
+
PHOTO = "Photo"
|
| 107 |
+
DOCUMENT = "Document"
|
| 108 |
+
|
| 109 |
+
DELETE = "Delete"
|
| 110 |
+
OPEN = "Open"
|
| 111 |
+
|
| 112 |
+
DOWNLOAD = "Download"
|
| 113 |
+
WATCH = "Watch"
|
| 114 |
+
FILES = "Files"
|
| 115 |
+
GROUPS = "Groups"
|
| 116 |
+
FILE = "File"
|
| 117 |
+
GROUP = "Group"
|
| 118 |
+
AGREED = "Agreed"
|
| 119 |
+
EXTERNAL_LINK = "External Link"
|
| 120 |
+
|
| 121 |
+
class kn(en):
|
| 122 |
+
# ─�� Start & Help ─────────────────────────────────────────────
|
| 123 |
+
START_TEXT = """ಯಾವುದೇ ಟೆಲಿಗ್ರಾಂ ಕಡತ ಅಥವಾ ಚಿತ್ರವನ್ನು ನನಗೆ ಕಳುಹಿಸಿ, ಅದಕ್ಕೆ ನಾನು ಒಂದು ಲಿಂಕ್ ರಚಿಸುತ್ತೇನೆ.
|
| 124 |
+
ಲಭ್ಯವಿರುವ ಆಜ್ಞೆಗಳನ್ನು ನೋಡಲು /help ಬಳಸಿ."""
|
| 125 |
+
|
| 126 |
+
HELP_TEXT = """
|
| 127 |
+
ಲಭ್ಯವಿರುವ ಆಜ್ಞೆಗಳು:
|
| 128 |
+
/start - ಬಾಟ್ ಅನ್ನು ಪ್ರಾರಂಭಿಸಿ
|
| 129 |
+
/help - ಈ ಸಹಾಯ ಸಂದೇಶವನ್ನು ತೋರಿಸಿ
|
| 130 |
+
/group - ಕಡತಗಳ ಗುಂಪನ್ನು ರಚಿಸಲು ಪ್ರಾರಂಭಿಸಿ
|
| 131 |
+
/done - ಗುಂಪಿಗೆ ಕಡತಗಳನ್ನು ಸೇರಿಸುವುದನ್ನು ಪೂರ್ಣಗೊಳಿಸಿ
|
| 132 |
+
/files - ನೀವು ಕಳುಹಿಸಿದ ಕಡತಗಳ ಮತ್ತು ರಚಿಸಿದ ಗುಂಪುಗಳ ಪಟ್ಟಿ
|
| 133 |
+
/setln - ನಿಮ್ಮ ಭಾಷೆಯನ್ನು ಬದಲಾಯಿಸಲು
|
| 134 |
+
/cancel - ಪ್ರಸ್ತುತ ಕಾರ್ಯಾಚರಣೆಯನ್ನು ರದ್ದುಗೊಳಿಸಲು
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
ACCEPTED_TOS_TEXT = "ನೀವು ಸೇವಾ ನಿಯಮಗಳಿಗೆ ಒಪ್ಪಿಗೆ ನೀಡಿದ್ದೀರಿ."
|
| 138 |
+
|
| 139 |
+
# ── Errors & State ────────────────────────────────────────────
|
| 140 |
+
UNKNOWN_COMMAND = "ಅಪರಿಚಿತ ಆಜ್ಞೆ / ಕಾರ್ಯ"
|
| 141 |
+
SOMETHING_WENT_WRONG = "ಏನೋ ತಪ್ಪಾಗಿದೆ. ದಯವಿಟ್ಟು ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ."
|
| 142 |
+
INAVLID_LINK_TEXT = "ಅಮಾನ್ಯ ಲಿಂಕ್."
|
| 143 |
+
INVALID_PAGE = "ಅಮಾನ್ಯ ಪುಟ."
|
| 144 |
+
|
| 145 |
+
ALREADY_IN_OP = "ನೀವು ಈಗಾಗಲೇ ಒಂದು ಕಾರ್ಯದಲ್ಲಿದ್ದೀರಿ. ಹೊಸದನ್ನು ಪ್ರಾರಂಭಿಸುವ ಮೊದಲು ಅದನ್ನು ಪೂರ್ಣಗೊಳಿಸಿ."
|
| 146 |
+
NOT_IN_OP = "ನೀವು ಯಾವುದೇ ಕಾರ್ಯದಲ್ಲಿಲ್ಲ."
|
| 147 |
+
|
| 148 |
+
FILE_ID_NOT_FOUND = "`{file_id}` ಗುರುತಿನ ಯಾವುದೇ ಫೈಲ್ ಕಂಡುಬಂದಿಲ್ಲ."
|
| 149 |
+
FILE_LOCATION_NOT_FOUND = "ಕಡತದ ಸ್ಥಳ ಕಂಡುಬಂದಿಲ್ಲ."
|
| 150 |
+
FILE_NOT_FOUND_TEXT = "ಕಡತ ಕಂಡುಬಂದಿಲ್ಲ."
|
| 151 |
+
GROUP_NOT_FOUND_TEXT = "ಗುಂಪು ಕಂಡುಬಂದಿಲ್ಲ."
|
| 152 |
+
|
| 153 |
+
# ── File & Group Info ─────────────────────────────────────────
|
| 154 |
+
FILES_TEXT = """ನೀವು ರಚಿಸಿದ ಲಿಂಕ್ಗಳು :
|
| 155 |
+
• ಕಡತಗಳು: {total_files}
|
| 156 |
+
• ಗುಂಪುಗಳು: {total_groups}
|
| 157 |
+
|
| 158 |
+
ನೀವು ನೋಡಲು ಬಯಸುವ ಲಿಂಕ್ಗಳ ಪ್ರಕಾರವನ್ನು ಆಯ್ಕೆಮಾಡಿ.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
FILE_INFO_TEXT = """ಫೈಲ್ ಮಾಹಿತಿ:
|
| 162 |
+
ಕಡತದ ಐಡಿ: {file_id}
|
| 163 |
+
ಡಿಸಿ ಐಡಿ: {dc_id}
|
| 164 |
+
ಗಾತ್ರ: {file_size}
|
| 165 |
+
ಮೈಮ್ ಪ್ರಕಾರ: {mime_type}
|
| 166 |
+
ಕಡತದ ಹೆಸರು: {file_name}
|
| 167 |
+
ಕಡತದ ಪ್ರಕಾರ: {file_type}
|
| 168 |
+
ನಿರ್ಬಂಧಿತವಾಗಿದೆ: {restricted}"""
|
| 169 |
+
|
| 170 |
+
GROUP_INFO_TEXT = """ಗುಂಪು ಮಾಹಿತಿ:
|
| 171 |
+
ಹೆಸರು: {name}
|
| 172 |
+
ರಚಿಸಿದ ದಿನಾಂಕ: {created_at}
|
| 173 |
+
ಒಟ್ಟು ಕಡತಗಳು: {total_files}"""
|
| 174 |
+
|
| 175 |
+
# ── Group Flow ────────────────────────────────────────────────
|
| 176 |
+
GROUP_NAME_TEXT = "ನಿಮ್ಮ ಕಡತಗಳ ಗುಂಪಿಗೆ ಒಂದು ಹೆಸರನ್ನು ಕಳುಹಿಸಿ"
|
| 177 |
+
GROUP_SENDFILE_TEXT = "ಗುಂಪಿಗೆ ಸೇರಿಸಲು ಕಡತಗಳನ್ನು ಕಳುಹಿಸಿ. ಮುಗಿದ ನಂತರ /done ಕಳುಹಿಸಿ"
|
| 178 |
+
GROUP_CREATED_TEXT = "ಗುಂಪು '{name}' ರಚಿಸಲಾಗಿದೆ!\n{url}"
|
| 179 |
+
GROUP_NOFILES_TEXT = "ಗುಂಪಿಗೆ ಯಾವುದೇ ಕಡತಗಳನ್ನು ಸೇರಿಸಲಾಗಿಲ್ಲ. ಕಾರ್ಯವನ್ನು ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ."
|
| 180 |
+
GROUP_ENDOF_FILES = "ಗುಂಪಿನ ಕಡತಗಳ ಅಂತ್ಯ."
|
| 181 |
+
|
| 182 |
+
# ── File Actions ──────────────────────────────────────────────
|
| 183 |
+
CONFIRM_DELETE_TEXT = "ನೀವು ಈ {label} ಅನ್ನು ಅಳಿಸಲು ಖಚಿತವಾಗಿದ್ದೀರಾ?"
|
| 184 |
+
DELETED_SUCCESSFULLY_TEXT = "{label} ಯಶಸ್ವಿಯಾಗಿ ಅಳಿಸಲಾಗಿದೆ."
|
| 185 |
+
SELECT_TYPE_OF_FILE = "ನೀವು ನೋಡಲು ಬಯಸುವ ಲಿಂಕ್ಗಳ ಪ್ರಕಾರವನ್ನು ��ಯ್ಕೆಮಾಡಿ."
|
| 186 |
+
GET_FILE_TEXT = "ಕಡತದ ಪಡೆಯಿರಿ"
|
| 187 |
+
|
| 188 |
+
# ── Lists & Pagination ────────────────────────────────────────
|
| 189 |
+
NO_LABEL_LINKS_TEXT = "ನೀವು ಇನ್ನೂ ಯಾವುದೇ {label}ಗಳ ಲಿಂಕ್ಗಳನ್ನು ರಚಿಸಿಲ್ಲ."
|
| 190 |
+
NO_LABELS_TEXT = "ಈ ಪುಟದಲ್ಲಿ ಯಾವುದೇ {label}ಗಳಿಲ್ಲ."
|
| 191 |
+
TOTAL_LABEL_COUNT = "ನಿಮ್ಮ ಬಳಿ ಒಟ್ಟು **{total}** {label}ಗಳಿವೆ."
|
| 192 |
+
FILES_BUTTON_CURRENT = "ಪುಟ {page_no}/{total_pages}"
|
| 193 |
+
|
| 194 |
+
# ── Languages ─────────────────────────────────────────────────
|
| 195 |
+
SETLN_USAGE_TEXT = "/setln <language_code>\nಉದಾಹರಣೆ: /setln en\nಬೆಂಬಲಿತ ಭಾಷಾ ಕೋಡ್ಗಳು: {supported_codes}"
|
| 196 |
+
SETLN_SET_TO = "ಭಾಷೆಯನ್ನು {ln_code} ಗೆ ಬದಲಾಯಿಸಲಾಗಿದೆ"
|
| 197 |
+
|
| 198 |
+
# ── Buttons & Labels ──────────────────────────────────────────
|
| 199 |
+
YES = "ಹೌದು"
|
| 200 |
+
NO = "ಇಲ್ಲ"
|
| 201 |
+
BACK_TEXT = "ಹಿಂತಿರುಗಿ"
|
| 202 |
+
|
| 203 |
+
PHOTO = "ಚಿತ್ರ"
|
| 204 |
+
DOCUMENT = "ದಾಖಲೆ"
|
| 205 |
+
|
| 206 |
+
DELETE = "ಅಳಿಸಿ"
|
| 207 |
+
OPEN = "ತೆರೆಯಿರಿ"
|
| 208 |
+
|
| 209 |
+
DOWNLOAD = "ಡೌನ್ಲೋಡ್"
|
| 210 |
+
WATCH = "ವೀಕ್ಷಿಸಿ"
|
| 211 |
+
FILES = "ಕಡತಗಳು"
|
| 212 |
+
GROUPS = "ಗುಂಪುಗಳು"
|
| 213 |
+
FILE = "ಕಡತ"
|
| 214 |
+
GROUP = "ಗುಂಪು"
|
| 215 |
+
AGREED = "ಒಪ್ಪಿಗೆ"
|
| 216 |
+
EXTERNAL_LINK = "ಬಾಹ್ಯ ಲಿಂಕ್"
|
| 217 |
+
|
| 218 |
+
registry = {
|
| 219 |
+
"en": en,
|
| 220 |
+
"kn": kn
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
def get_lang(iso_code: Union[str, User] = None) -> en:
|
| 224 |
+
if isinstance(iso_code, User):
|
| 225 |
+
iso_code = iso_code.preferred_lang
|
| 226 |
+
return registry.get(iso_code, en)
|
tgfs/utils/types.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import datetime
|
| 18 |
+
from typing import Optional, Union, Any
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from enum import Enum
|
| 21 |
+
|
| 22 |
+
from telethon.tl import types
|
| 23 |
+
|
| 24 |
+
from tgfs.config import Config
|
| 25 |
+
|
| 26 |
+
InputTypeLocation = Union[types.InputDocumentFileLocation, types.InputPhotoFileLocation]
|
| 27 |
+
InputMedia = Union[types.Document, types.Photo, types.PhotoEmpty, types.DocumentEmpty]
|
| 28 |
+
SupportedType = Union[bytes, bool, int, str, list, dict]
|
| 29 |
+
|
| 30 |
+
class Status(Enum):
|
| 31 |
+
NO_OP=0
|
| 32 |
+
GROUP=1
|
| 33 |
+
GROUP_NAME=2
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class FileInfo:
|
| 37 |
+
__slots__ = ("id", "dc_id", "file_size", "mime_type", "file_name", "thumb_size", "is_deleted")
|
| 38 |
+
id: int
|
| 39 |
+
dc_id: int
|
| 40 |
+
file_size: int
|
| 41 |
+
mime_type: str
|
| 42 |
+
file_name: str
|
| 43 |
+
thumb_size: str
|
| 44 |
+
is_deleted: bool
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class FileSource:
|
| 48 |
+
chat_id: int
|
| 49 |
+
message_id: int
|
| 50 |
+
time: Optional[datetime.datetime] = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class User:
|
| 55 |
+
user_id: int
|
| 56 |
+
join_date: Optional[datetime.datetime] = None
|
| 57 |
+
ban_date: Optional[datetime.datetime] = None
|
| 58 |
+
warns: int = 0
|
| 59 |
+
preferred_lang: str = "en" # 2-letter ISO code, default en
|
| 60 |
+
curt_op: Optional[Status] = Status.NO_OP
|
| 61 |
+
op_id: Optional[int] = 0
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def is_banned(self) -> bool:
|
| 65 |
+
return self.ban_date is not None
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def is_admin(self) -> bool:
|
| 69 |
+
return self.user_id in Config.ADMIN_IDS
|
| 70 |
+
|
| 71 |
+
@classmethod
|
| 72 |
+
def from_row(cls, row: dict[str, Any]) -> "User":
|
| 73 |
+
|
| 74 |
+
def parse_dt(val):
|
| 75 |
+
if isinstance(val, datetime.datetime):
|
| 76 |
+
return val
|
| 77 |
+
if isinstance(val, (str, bytes)):
|
| 78 |
+
try:
|
| 79 |
+
s = val.decode() if isinstance(val, bytes) else val
|
| 80 |
+
return datetime.datetime.fromisoformat(s)
|
| 81 |
+
except Exception: # pylint: disable=W0718
|
| 82 |
+
return None
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
return cls(
|
| 86 |
+
user_id = int(row["user_id"]),
|
| 87 |
+
join_date = parse_dt(row.get("join_date")),
|
| 88 |
+
ban_date = parse_dt(row.get("ban_date")),
|
| 89 |
+
warns = int(row.get("warns", 0)),
|
| 90 |
+
preferred_lang = row.get("preferred_lang") or "en",
|
| 91 |
+
curt_op = Status(row.get("curt_op")),
|
| 92 |
+
op_id = int(row.get("op_id"))
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
@dataclass
|
| 96 |
+
class GroupInfo:
|
| 97 |
+
group_id: int
|
| 98 |
+
user_id: int
|
| 99 |
+
name: str
|
| 100 |
+
created_at: Optional[datetime.datetime]
|
| 101 |
+
files: Optional[list[int]] = None
|
tgfs/utils/utils.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TG-FileStream
|
| 2 |
+
# Copyright (C) 2025-2026 Deekshith SH
|
| 3 |
+
|
| 4 |
+
# This program is free software: you can redistribute it and/or modify
|
| 5 |
+
# it under the terms of the GNU Affero General Public License as published by
|
| 6 |
+
# the Free Software Foundation, either version 3 of the License, or
|
| 7 |
+
# (at your option) any later version.
|
| 8 |
+
|
| 9 |
+
# This program is distributed in the hope that it will be useful,
|
| 10 |
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
+
# GNU Affero General Public License for more details.
|
| 13 |
+
|
| 14 |
+
# You should have received a copy of the GNU Affero General Public License
|
| 15 |
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import sys
|
| 19 |
+
import base64
|
| 20 |
+
import hashlib
|
| 21 |
+
import hmac
|
| 22 |
+
import struct
|
| 23 |
+
import time
|
| 24 |
+
import importlib.metadata
|
| 25 |
+
from typing import Optional, cast
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
from telethon import Button
|
| 29 |
+
from telethon.utils import get_input_location
|
| 30 |
+
from telethon.tl.custom import Message
|
| 31 |
+
|
| 32 |
+
from tgfs.config import Config
|
| 33 |
+
from tgfs.paralleltransfer import ParallelTransferrer
|
| 34 |
+
from tgfs.utils.types import FileSource, InputTypeLocation, User
|
| 35 |
+
from tgfs.telegram import client
|
| 36 |
+
from tgfs.database import DB
|
| 37 |
+
|
| 38 |
+
log = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
START_TIME = time.monotonic()
|
| 41 |
+
|
| 42 |
+
async def update_location(source: FileSource, transfer: ParallelTransferrer) -> InputTypeLocation:
|
| 43 |
+
message = cast(Message,await client.forward_messages(
|
| 44 |
+
Config.BIN_CHANNEL, source.message_id, source.chat_id, drop_author=True))
|
| 45 |
+
msg = cast(Message, await transfer.client.get_messages(message.chat_id, ids=message.id))
|
| 46 |
+
await message.delete()
|
| 47 |
+
_, location = get_input_location(msg)
|
| 48 |
+
await DB.db.upsert_location(transfer.client_id, location)
|
| 49 |
+
return location
|
| 50 |
+
|
| 51 |
+
async def check_get_user(user_id: int, msg_id, required: bool = True) -> Optional[User]:
|
| 52 |
+
if Config.ALLOWED_IDS and user_id not in Config.ALLOWED_IDS:
|
| 53 |
+
return None
|
| 54 |
+
user = await DB.db.get_user(user_id)
|
| 55 |
+
if required and user is None:
|
| 56 |
+
await client.send_message(
|
| 57 |
+
user_id, "Please agree to the Terms of Service before using the bot.",
|
| 58 |
+
buttons=[[Button.inline('Agree', f'tos_agree_{msg_id}'.encode('utf-8'))]]
|
| 59 |
+
)
|
| 60 |
+
if user is not None and user.is_banned:
|
| 61 |
+
await client.send_message(user_id, "You are banned from using this bot.")
|
| 62 |
+
return None
|
| 63 |
+
return user
|
| 64 |
+
|
| 65 |
+
async def load_configs():
|
| 66 |
+
Config.SECRET = await DB.db.get_secret()
|
| 67 |
+
|
| 68 |
+
def base64_encode(data: bytes) -> str:
|
| 69 |
+
encoded = base64.urlsafe_b64encode(data)
|
| 70 |
+
return encoded.decode('ascii').rstrip("=")
|
| 71 |
+
|
| 72 |
+
def base64_decode(string: str) -> bytes:
|
| 73 |
+
padding = 4 - (len(string) % 4)
|
| 74 |
+
string = string + ("=" * padding)
|
| 75 |
+
return base64.urlsafe_b64decode(string)
|
| 76 |
+
|
| 77 |
+
def make_token(user_id: int, file_id: int) -> str:
|
| 78 |
+
payload = struct.pack(">QQ", user_id, file_id)
|
| 79 |
+
sig = hmac.new(Config.SECRET, payload, hashlib.sha256).digest()
|
| 80 |
+
|
| 81 |
+
token = (
|
| 82 |
+
base64_encode(payload)
|
| 83 |
+
+ "/"
|
| 84 |
+
+ base64_encode(sig)
|
| 85 |
+
)
|
| 86 |
+
return token
|
| 87 |
+
|
| 88 |
+
def parse_token(p_b64: str, s_b64: Optional[str] = None) -> tuple[int, int] | None:
|
| 89 |
+
try:
|
| 90 |
+
payload = base64_decode(p_b64)
|
| 91 |
+
sig = base64_decode(s_b64)
|
| 92 |
+
|
| 93 |
+
if s_b64 is not None:
|
| 94 |
+
expected = hmac.new(Config.SECRET, payload, hashlib.sha256).digest()
|
| 95 |
+
if not hmac.compare_digest(sig, expected):
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
user_id, file_id = struct.unpack(">QQ", payload)
|
| 99 |
+
return user_id, file_id
|
| 100 |
+
except Exception: # pylint: disable=W0718
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
def human_time(seconds: int):
|
| 104 |
+
mins, sec = divmod(seconds, 60)
|
| 105 |
+
hrs, mins = divmod(mins, 60)
|
| 106 |
+
days, hrs = divmod(hrs, 24)
|
| 107 |
+
return f"{days}d {hrs}h {mins}m {sec}s"
|
| 108 |
+
|
| 109 |
+
def uptime_human():
|
| 110 |
+
return human_time(int(time.monotonic() - START_TIME))
|
| 111 |
+
|
| 112 |
+
def human_bytes(size: int) -> str:
|
| 113 |
+
if size == 0:
|
| 114 |
+
return "0 B"
|
| 115 |
+
units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
|
| 116 |
+
i = 0
|
| 117 |
+
while size >= 1024 and i < len(units) - 1:
|
| 118 |
+
size /= 1024
|
| 119 |
+
i += 1
|
| 120 |
+
return f"{size:.2f} {units[i]}"
|
| 121 |
+
|
| 122 |
+
def load_patches(patches_path=None):
|
| 123 |
+
logger = log.getChild("patch")
|
| 124 |
+
if patches_path:
|
| 125 |
+
load_local_patches(patches_path, logger)
|
| 126 |
+
|
| 127 |
+
load_entrypoint_plugins(logger)
|
| 128 |
+
|
| 129 |
+
def load_local_patches(patches_path: str, logger: logging.Logger):
|
| 130 |
+
patches_path = Path(patches_path).resolve()
|
| 131 |
+
if not patches_path.exists():
|
| 132 |
+
return
|
| 133 |
+
project_root = patches_path.parent.parent
|
| 134 |
+
if str(project_root) not in sys.path:
|
| 135 |
+
sys.path.insert(0, str(project_root))
|
| 136 |
+
|
| 137 |
+
package_name = f"{patches_path.parent.name}.{patches_path.name}"
|
| 138 |
+
|
| 139 |
+
for item in patches_path.iterdir():
|
| 140 |
+
if item.is_file() and item.suffix == ".py":
|
| 141 |
+
logger.info("Loading %s", item.stem)
|
| 142 |
+
module_name = f"{package_name}.{item.stem}"
|
| 143 |
+
importlib.import_module(module_name)
|
| 144 |
+
elif item.is_dir() and item.name != "__pycache__":
|
| 145 |
+
logger.info("Loading %s", item.name)
|
| 146 |
+
if (item / "__init__.py").exists():
|
| 147 |
+
module_name = f"{package_name}.{item.name}"
|
| 148 |
+
importlib.import_module(module_name)
|
| 149 |
+
else:
|
| 150 |
+
for file in item.rglob("*.py"):
|
| 151 |
+
relative = file.relative_to(patches_path)
|
| 152 |
+
module_parts = relative.with_suffix("").parts
|
| 153 |
+
module_name = ".".join((package_name, *module_parts))
|
| 154 |
+
importlib.import_module(module_name)
|
| 155 |
+
|
| 156 |
+
def load_entrypoint_plugins(logger: logging.Logger):
|
| 157 |
+
for ep in importlib.metadata.entry_points(group="tgfs.plugins"):
|
| 158 |
+
try:
|
| 159 |
+
logger.info("Loading %s", ep.name)
|
| 160 |
+
importlib.import_module(ep.value)
|
| 161 |
+
except Exception as e: # pylint: disable=W0718
|
| 162 |
+
print(f"Failed to load plugin {ep.name}: {e}")
|