├── .gitignore ├── LICENSE ├── README.md ├── api.env ├── apps ├── Dockerfile-api ├── Dockerfile-tgbot ├── __init__.py ├── api │ ├── README.rst │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── app.py │ │ ├── checkers │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── http.py │ │ │ ├── icmp.py │ │ │ ├── minecraft.py │ │ │ ├── port.py │ │ │ └── tcp_port.py │ │ ├── config.py │ │ ├── helpers.py │ │ └── patched.py │ └── pyproject.toml ├── core │ ├── README.rst │ ├── __init__.py │ ├── core │ │ └── coretypes.py │ └── pyproject.toml └── tgbot │ ├── README.rst │ ├── pyproject.toml │ ├── test │ ├── __init__.py │ └── test_port_parsers.py │ └── tgbot │ ├── __init__.py │ ├── bot.py │ ├── config.py │ ├── handlers │ ├── __init__.py │ ├── base.py │ ├── default │ │ ├── __init__.py │ │ ├── icmp.py │ │ ├── ipcalc.py │ │ ├── minecraft.py │ │ ├── start.py │ │ ├── tcp.py │ │ ├── web.py │ │ └── whois.py │ ├── errors.py │ ├── helpers.py │ ├── metrics.py │ ├── validators.py │ └── whois_zones.py │ ├── middlewares │ ├── __init__.py │ ├── logging.py │ ├── throttling.py │ ├── userdata.py │ └── write_command_metric.py │ ├── models │ ├── __init__.py │ ├── requests.py │ └── user.py │ └── nodes.py ├── docker-compose-api.yml ├── docker-compose-tgbot.yml ├── pyproject.toml └── tgbot.env /.gitignore: -------------------------------------------------------------------------------- 1 | # Editors 2 | .vscode/ 3 | .idea/ 4 | 5 | # Vagrant 6 | .vagrant/ 7 | 8 | # Mac/OSX 9 | .DS_Store 10 | 11 | # Windows 12 | Thumbs.db 13 | 14 | # Source for the following rules: https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # poetry lock 128 | poetry.lock 129 | 130 | # env files 131 | .env 132 | 133 | # nodes file 134 | nodes.py 135 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 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 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Universal Checker Bot 3 |

4 |

5 | 6 |

7 | 8 | Данный бот служит для получения различной информации о хосте с нескольких нод. 9 | 10 | Часто возникают случаи, когда необходимо посмотреть, как поведет себя тот или инной ресурс с различных IP адресов. Например - посмотреть задержку или заблокирован ли порт для определенного региона. 11 | 12 | ### Работает это таким образом: 13 | 14 | * На удаленные сервера устанавливается API-сервер 15 | * На ещё один сервер(или рядом) устанавливается бот 16 | * В настройках бота (в файле `nodes.py`) указываются адреса серверов API 17 | * В зависимости от команды бот получает информацию с указанных нод 18 | * Архитектура не отменяет того, что в боте есть команды, которые выполняются на хосте где установлен бот. 19 | 20 | Все команды, которые есть сейчас, можно посмотреть [в самом боте](https://t.me/unicheckbot), для этого напишите в нём /start 21 | 22 | ### Установка 23 | * Установите git, docker и docker-compose 24 | * Склонируйте репозиторий: `git clone https://github.com/catspace-dev/unicheckbot` 25 | 26 | #### Установка API сервера 27 | * Настройте параметры в `api.env` 28 | * Запустите `docker-compose -f docker-compose-api.yml --env-file api.env up -d` 29 | * В боте по пути `apps/tgbot/tgbot/nodes.py` добавьте ноду как указано в примере и перезапустите бота. 30 | #### Установка бота 31 | * Настройте параметры в `tgbot.env` 32 | * Запустите `docker-compose -f docker-compose-tgbot.yml --env-file tgbot.env up -d` 33 | 34 | # Проект теперь разрабатывается по другой модели 35 | На данный момент актуальная версия проекта находится [здесь](https://github.com/Unicheckbot). Форк был сделан по ряду причин (одна из них - неудачная архитектура проекта), описывать которых я абсолютно не вижу смысла. 36 | 37 | Если у вас есть какие-то предложения по боту или какие-то проблемы с ним - можете оставлять ваши [issues](https://github.com/Unicheckbot/unicheckbot) тут. 38 | 39 | Нода для бота находится [тут](https://github.com/Unicheckbot/rei). Исходники **НОВОЙ ВЕРСИИ** бота теперь закрыты. Исходники старой версии и сам бот находятся здесь, вы можете их использовать. 40 | -------------------------------------------------------------------------------- /api.env: -------------------------------------------------------------------------------- 1 | APP_PORT=8080 2 | WORKERS=2 3 | ASYNC_CORES=2000 4 | NODE_NAME=Default node 5 | NODE_LOCATION=🏳️‍🌈 Default, Localtion 6 | ACCESS_TOKEN=CHANGE_TOKEN 7 | -------------------------------------------------------------------------------- /apps/Dockerfile-api: -------------------------------------------------------------------------------- 1 | FROM python:3.9.4-slim-buster 2 | 3 | WORKDIR /usr/src/app 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | RUN apt update && apt install gcc -y 8 | COPY . . 9 | WORKDIR api/api 10 | RUN pip install --upgrade pip; pip install poetry; poetry config virtualenvs.create false; poetry install; poetry add uwsgi 11 | CMD poetry shell; uwsgi --master \ 12 | --single-interpreter \ 13 | --workers $WORKERS \ 14 | --gevent $ASYNC_CORES \ 15 | --protocol http \ 16 | --socket 0.0.0.0:$APP_PORT \ 17 | --module patched:app 18 | -------------------------------------------------------------------------------- /apps/Dockerfile-tgbot: -------------------------------------------------------------------------------- 1 | FROM python:3.9.4-slim-buster 2 | 3 | WORKDIR /usr/src/app 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | RUN apt update && apt install gcc -y 8 | COPY . . 9 | WORKDIR tgbot 10 | RUN pip install --upgrade pip; pip install poetry; poetry config virtualenvs.create false; poetry install 11 | CMD poetry run tgbot 12 | -------------------------------------------------------------------------------- /apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/__init__.py -------------------------------------------------------------------------------- /apps/api/README.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/api/README.rst -------------------------------------------------------------------------------- /apps/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/api/__init__.py -------------------------------------------------------------------------------- /apps/api/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/api/api/__init__.py -------------------------------------------------------------------------------- /apps/api/api/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, abort, jsonify 2 | from gevent.pywsgi import WSGIServer 3 | from helpers import access_token_required 4 | import config 5 | 6 | from checkers import HttpChecker, ICMPChecker, TCPPortChecker, MinecraftChecker 7 | 8 | app = Flask(__name__) 9 | 10 | 11 | @app.route('/http') 12 | @access_token_required 13 | def http_check(): 14 | target = request.args.get("target", None) 15 | port = int(request.args.get("port", 80)) 16 | if not target: 17 | abort(400) 18 | checker = HttpChecker(target, port) 19 | return jsonify(checker.check()) 20 | 21 | 22 | @app.route('/tcp_port') 23 | @access_token_required 24 | def tcp_port_check(): 25 | target = request.args.get("target", None) 26 | port = int(request.args.get("port", None)) 27 | if not target or not port: 28 | abort(400) 29 | checker = TCPPortChecker(target, port) 30 | return jsonify(checker.check()) 31 | 32 | 33 | @app.route('/minecraft') 34 | @access_token_required 35 | def minecraft_check(): 36 | target = request.args.get("target", None) 37 | port = int(request.args.get("port", 25565)) 38 | if not target: 39 | abort(400) 40 | checker = MinecraftChecker(target, port) 41 | return jsonify(checker.check()) 42 | 43 | 44 | @app.route('/icmp') 45 | @access_token_required 46 | def icmp_check(): 47 | target = request.args.get("target", None) 48 | if not target: 49 | abort(400) 50 | checker = ICMPChecker(target) 51 | return jsonify(checker.check()) 52 | 53 | 54 | def main(): 55 | http = WSGIServer(('', config.APP_PORT), app.wsgi_app) 56 | http.serve_forever() 57 | 58 | 59 | if __name__ == '__main__': 60 | main() 61 | -------------------------------------------------------------------------------- /apps/api/api/checkers/__init__.py: -------------------------------------------------------------------------------- 1 | from .http import HttpChecker 2 | from .icmp import ICMPChecker 3 | from .tcp_port import TCPPortChecker 4 | from .minecraft import MinecraftChecker 5 | -------------------------------------------------------------------------------- /apps/api/api/checkers/base.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import Response, APINodeInfo 2 | from api.config import NODE_NAME, NODE_LOCATION 3 | from abc import ABC 4 | 5 | 6 | class BaseChecker(ABC): 7 | 8 | def __init__(self, target: str): 9 | self.target = target 10 | self.node_info = APINodeInfo( 11 | name=NODE_NAME, location=NODE_LOCATION 12 | ) 13 | 14 | def check(self) -> Response: 15 | raise NotImplementedError 16 | -------------------------------------------------------------------------------- /apps/api/api/checkers/http.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import ( 2 | Response, HttpCheckerResponse, 3 | ResponseStatus, ErrorCodes, ErrorPayload, 4 | ) 5 | from requests import Session 6 | from requests.exceptions import ConnectionError 7 | from .base import BaseChecker 8 | import time 9 | import re 10 | 11 | 12 | class HttpChecker(BaseChecker): 13 | 14 | default_schema = "http://" 15 | default_schema_re = re.compile("^[hH][tT][tT][pP].*") 16 | 17 | def __init__(self, target: str, port: int): 18 | super(HttpChecker, self).__init__(target) 19 | self.port = port 20 | self.session = Session() 21 | 22 | def check(self) -> Response: 23 | 24 | url = f"{self.target}:{self.port}" 25 | if not self.default_schema_re.match(url): 26 | url = f"{self.default_schema}{url}" 27 | 28 | start_time = time.time() 29 | try: 30 | request = self.session.head( 31 | url, 32 | allow_redirects=True, 33 | ) 34 | # TODO: requests.exceptions.InvalidURL failed to parse exception 35 | except ConnectionError: 36 | return Response( 37 | status=ResponseStatus.ERROR, 38 | payload=ErrorPayload( 39 | message="Failed to establish a new connection", 40 | code=ErrorCodes.ConnectError, 41 | ), 42 | node=self.node_info 43 | ) 44 | 45 | end_time = time.time() 46 | 47 | return Response( 48 | status=ResponseStatus.OK, 49 | payload=HttpCheckerResponse( 50 | time=end_time - start_time, 51 | status_code=request.status_code 52 | ), 53 | node=self.node_info 54 | ) 55 | -------------------------------------------------------------------------------- /apps/api/api/checkers/icmp.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import Response, ErrorPayload, ErrorCodes, ResponseStatus, ICMPCheckerResponse 2 | from .base import BaseChecker 3 | from icmplib import ping 4 | from icmplib.exceptions import NameLookupError 5 | 6 | 7 | class ICMPChecker(BaseChecker): 8 | 9 | def __init__(self, target: str): 10 | super().__init__(target) 11 | 12 | def create_not_alive_response(self): 13 | return Response( 14 | status=ResponseStatus.ERROR, 15 | payload=ErrorPayload( 16 | code=ErrorCodes.ICMPHostNotAlive, 17 | message="Host not available for ICMP ping" 18 | ), 19 | node=self.node_info 20 | ) 21 | 22 | def check(self) -> Response: 23 | 24 | try: 25 | host = ping(self.target) 26 | except NameLookupError: 27 | return self.create_not_alive_response() 28 | 29 | # TODO: ban ping localhost 30 | if not host.is_alive: 31 | return self.create_not_alive_response() 32 | 33 | return Response( 34 | status=ResponseStatus.OK, 35 | payload=ICMPCheckerResponse( 36 | min_rtt=host.min_rtt, 37 | max_rtt=host.max_rtt, 38 | avg_rtt=host.avg_rtt, 39 | packets_sent=host.packets_sent, 40 | packets_received=host.packets_received, 41 | loss=host.packet_loss, 42 | ), 43 | node=self.node_info 44 | ) 45 | -------------------------------------------------------------------------------- /apps/api/api/checkers/minecraft.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import Response, PortResponse, ErrorPayload, ErrorCodes, MinecraftResponse, ResponseStatus 2 | from mcstatus import MinecraftServer 3 | from .base import BaseChecker 4 | import socket 5 | 6 | 7 | class MinecraftChecker(BaseChecker): 8 | 9 | def __init__(self, target: str, port: int): 10 | self.port = port 11 | super().__init__(target) 12 | 13 | def check(self) -> Response: 14 | try: 15 | server = MinecraftServer.lookup(f"{self.target}:{self.port}") 16 | status = server.status() 17 | except socket.gaierror: 18 | return Response( 19 | status=ResponseStatus.ERROR, 20 | payload=ErrorPayload( 21 | message="Server does not respond!", 22 | code=ErrorCodes.ConnectError 23 | ), 24 | node=self.node_info 25 | ) 26 | return Response( 27 | status=ResponseStatus.OK, 28 | payload=MinecraftResponse( 29 | latency=status.latency, 30 | max_players=status.players.max, 31 | online=status.players.online 32 | ), 33 | node=self.node_info 34 | ) 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /apps/api/api/checkers/port.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import Response, PortResponse, ResponseStatus, ErrorPayload, ErrorCodes 2 | from .base import BaseChecker 3 | import socket 4 | 5 | 6 | class PortChecker(BaseChecker): 7 | 8 | def __init__(self, target: str, port: int, sock: socket.socket): 9 | self.port = port 10 | self.sock = sock 11 | super().__init__(target) 12 | 13 | def check(self) -> Response: 14 | # 2 seconds timeout... 15 | self.sock.settimeout(2) 16 | 17 | try: 18 | res = self.sock.connect_ex((self.target, self.port)) 19 | except socket.gaierror: 20 | return Response( 21 | status=ResponseStatus.ERROR, 22 | payload=ErrorPayload( 23 | message="Invalid hostname", 24 | code=ErrorCodes.InvalidHostname 25 | ), 26 | node=self.node_info 27 | ) 28 | if res == 0: 29 | self.sock.close() 30 | return Response( 31 | status=ResponseStatus.OK, 32 | payload=PortResponse(open=True), 33 | node=self.node_info 34 | ) 35 | self.sock.close() 36 | return Response( 37 | status=ResponseStatus.OK, 38 | payload=PortResponse(open=False), 39 | node=self.node_info 40 | ) 41 | 42 | 43 | -------------------------------------------------------------------------------- /apps/api/api/checkers/tcp_port.py: -------------------------------------------------------------------------------- 1 | from .port import PortChecker 2 | import socket 3 | 4 | 5 | class TCPPortChecker(PortChecker): 6 | 7 | def __init__(self, target: str, port: int): 8 | super().__init__( 9 | target=target, 10 | port=port, 11 | sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM) 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /apps/api/api/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # APP PORT 4 | APP_PORT = os.environ.get("PORT", 8080) 5 | 6 | # Node name. Will be shown in tgbot 7 | NODE_NAME = os.environ.get("NODE_NAME", "Default node") 8 | 9 | # Node location. Will be shown in tgbot 10 | NODE_LOCATION = os.environ.get("NODE_LOCATION", "🏳️‍🌈 Undefined, Location") 11 | 12 | # Access token. Will be used for requests 13 | ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN", "CHANGE_TOKEN_BY_ENV") 14 | -------------------------------------------------------------------------------- /apps/api/api/helpers.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import request, abort 3 | from config import ACCESS_TOKEN 4 | 5 | 6 | def access_token_required(f): 7 | @wraps(f) 8 | def decorated(*args, **kwargs): 9 | if token := request.args.get('token', None): 10 | if token == ACCESS_TOKEN: 11 | return f(*args, **kwargs) 12 | abort(403) 13 | return decorated 14 | -------------------------------------------------------------------------------- /apps/api/api/patched.py: -------------------------------------------------------------------------------- 1 | from gevent import monkey 2 | monkey.patch_all() 3 | 4 | from api.app import app 5 | -------------------------------------------------------------------------------- /apps/api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "api" 3 | version = "0.1.0" 4 | description = "Host report API" 5 | authors = ["kiriharu "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8.2" 9 | Flask = "^1.1.2" 10 | gevent = "^20.12.1" 11 | requests = "^2.25.1" 12 | icmplib = "^2.0.1" 13 | mcstatus = "^4.1.0" 14 | core = {path = "../core"} 15 | 16 | [tool.poetry.dev-dependencies] 17 | 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /apps/core/README.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/core/README.rst -------------------------------------------------------------------------------- /apps/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/core/__init__.py -------------------------------------------------------------------------------- /apps/core/core/coretypes.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum, IntEnum 3 | 4 | 5 | class Payload: 6 | pass 7 | 8 | 9 | class ResponseStatus(str, Enum): 10 | OK = "ok" 11 | ERROR = "error" 12 | 13 | 14 | class ErrorCodes(IntEnum): 15 | ConnectError = 1 16 | ICMPHostNotAlive = 2 17 | InvalidHostname = 3 18 | 19 | 20 | @dataclass 21 | class ErrorPayload(Payload): 22 | message: str 23 | code: ErrorCodes 24 | 25 | 26 | @dataclass 27 | class HttpCheckerResponse(Payload): 28 | status_code: int 29 | time: float 30 | 31 | 32 | @dataclass 33 | class ICMPCheckerResponse(Payload): 34 | min_rtt: float 35 | avg_rtt: float 36 | max_rtt: float 37 | packets_sent: int 38 | packets_received: int 39 | loss: float 40 | 41 | 42 | @dataclass 43 | class APINodeInfo: 44 | name: str 45 | location: str 46 | 47 | 48 | @dataclass 49 | class MinecraftResponse(Payload): 50 | latency: float 51 | max_players: int 52 | online: int 53 | 54 | @dataclass 55 | class PortResponse(Payload): 56 | open: bool 57 | 58 | 59 | @dataclass 60 | class Response: 61 | status: ResponseStatus 62 | payload: Payload 63 | node: APINodeInfo 64 | 65 | 66 | @dataclass 67 | class APINode: 68 | address: str 69 | token: str 70 | 71 | 72 | HTTP_EMOJI = { 73 | 2: "✅", 74 | 3: "➡️", 75 | 4: "🔍", 76 | 5: "❌️", 77 | } 78 | -------------------------------------------------------------------------------- /apps/core/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "core" 3 | version = "0.10.0" 4 | description = "Types and other core functionality" 5 | authors = ["kiriharu "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | 10 | [tool.poetry.dev-dependencies] 11 | 12 | [build-system] 13 | requires = ["poetry-core>=1.0.0"] 14 | build-backend = "poetry.core.masonry.api" 15 | -------------------------------------------------------------------------------- /apps/tgbot/README.rst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/tgbot/README.rst -------------------------------------------------------------------------------- /apps/tgbot/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tgbot" 3 | version = "0.1.0" 4 | description = "Telegram bot" 5 | authors = ["kiriharu "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | core = {path = "../core"} 10 | aiogram = "^2.11.2" 11 | httpx = "^0.16.1" 12 | python-whois = "^0.7.3" 13 | aioinflux = "^0.9.0" 14 | loguru = "^0.5.3" 15 | whois-vu = "^0.3.0" 16 | tortoise-orm = "^0.16.20" 17 | aiomysql = "^0.0.21" 18 | sentry-sdk = "^1.0.0" 19 | aiocontextvars = "^0.2.2" 20 | 21 | [tool.poetry.dev-dependencies] 22 | pytest = "^6.2.2" 23 | flake8 = "^3.8.4" 24 | 25 | [build-system] 26 | requires = ["poetry-core>=1.0.0"] 27 | build-backend = "poetry.core.masonry.api" 28 | 29 | [tool.poetry.scripts] 30 | tgbot = "tgbot.bot:main" -------------------------------------------------------------------------------- /apps/tgbot/test/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/tgbot/test/test_port_parsers.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from tgbot.handlers.default.tcp import TCPCheckerHandler 4 | from tgbot.handlers.base import process_args_for_host_port,\ 5 | NotEnoughArgs, InvalidPort 6 | 7 | 8 | class TestArgsProc(TestCase): 9 | 10 | def test_exceptions(self): 11 | """Test exceptions being raised 12 | on invalid commands 13 | """ 14 | cases = [ 15 | ('/cmd', NotEnoughArgs), 16 | ('/cmd example.com testsarenice', InvalidPort) 17 | ] 18 | for cmd, exc in cases: 19 | with self.subTest(command=cmd): 20 | self.assertRaises( 21 | exc, 22 | process_args_for_host_port, cmd, 443 23 | ) 24 | 25 | def test_host_port(self): 26 | """Test that host and port are parsed correctly 27 | """ 28 | cases = [ 29 | ('/cmd example.com', 'example.com', 443), 30 | ('/cmd example.com 42', 'example.com', 42), 31 | ('/cmd example.com:42', 'example.com', 42) 32 | ] 33 | 34 | for cmd, host, port in cases: 35 | with self.subTest(cmd=cmd, host=host, port=port): 36 | test_host, test_port = process_args_for_host_port(cmd, 443) 37 | self.assertEqual(test_host, host) 38 | self.assertEqual(test_port, port) 39 | 40 | 41 | class TestTCPCheckerHandler(TestCase): 42 | def setUp(self) -> None: 43 | self.method = TCPCheckerHandler().process_args 44 | return super().setUp() 45 | 46 | def test_exceptions(self): 47 | """Test all appropriate excpetions are raised. 48 | """ 49 | cases = [ 50 | ('/cmd', NotEnoughArgs), 51 | ('/cmd example.com', NotEnoughArgs), 52 | ('/cmd example.com jdbnjsbndjsd', InvalidPort) 53 | ] 54 | 55 | for cmd, exc in cases: 56 | with self.subTest(cmd=cmd): 57 | self.assertRaises( 58 | exc, 59 | self.method, cmd 60 | ) 61 | 62 | def test_host_port(self): 63 | """Test that host and port are parsed correctly 64 | """ 65 | cases = [ 66 | ('/cmd example.com 42', 'example.com', 42), 67 | ('/cmd example.com:65', 'example.com', 65) 68 | ] 69 | 70 | for cmd, host, port in cases: 71 | with self.subTest(cmd=cmd, host=host, port=port): 72 | test_host, test_port = self.method(cmd) 73 | self.assertEqual(test_host, host) 74 | self.assertEqual(test_port, port) 75 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catspace-dev/unicheckbot/c654fb7b08d1baa050f7ae8df07be8ef3563a93e/apps/tgbot/tgbot/__init__.py -------------------------------------------------------------------------------- /apps/tgbot/tgbot/bot.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from tgbot import config 3 | 4 | if config.SENTRY_DSN: 5 | 6 | sentry_sdk.init( 7 | dsn=config.SENTRY_DSN, 8 | ) 9 | 10 | from asyncio import sleep 11 | 12 | from aiogram import Bot, Dispatcher, executor 13 | from aiogram.contrib.fsm_storage.memory import MemoryStorage 14 | from loguru import logger 15 | from tortoise import Tortoise 16 | from tortoise.exceptions import DBConnectionError 17 | 18 | from . import handlers 19 | from .middlewares import ( 20 | LoggingMiddleware, ThrottlingMiddleware, UserMiddleware, WriteCommandMetric 21 | ) 22 | 23 | storage = MemoryStorage() 24 | telegram_bot = Bot(token=config.TELEGRAM_BOT_TOKEN) 25 | dp = Dispatcher(telegram_bot, storage=storage) 26 | 27 | 28 | async def database_init(): 29 | if config.MYSQL_HOST is not None: 30 | db_url = f"mysql://{config.MYSQL_USER}:{config.MYSQL_PASSWORD}@" \ 31 | f"{config.MYSQL_HOST}:{config.MYSQL_PORT}/{config.MYSQL_DATABASE}" 32 | else: 33 | db_url = "sqlite://db.sqlite3" 34 | try: 35 | await Tortoise.init( 36 | db_url=db_url, 37 | modules={ 38 | 'models': ['tgbot.models'] 39 | } 40 | ) 41 | except DBConnectionError: 42 | logger.error("Connection to database failed.") 43 | await sleep(10) 44 | await database_init() 45 | await Tortoise.generate_schemas() 46 | logger.info("Tortoise inited!") 47 | 48 | 49 | async def on_startup(disp: Dispatcher): 50 | await database_init() 51 | handlers.default.setup(disp) 52 | disp.middleware.setup(ThrottlingMiddleware()) 53 | disp.middleware.setup(WriteCommandMetric()) 54 | disp.middleware.setup(LoggingMiddleware()) 55 | disp.middleware.setup(UserMiddleware()) 56 | 57 | 58 | def main(): 59 | executor.start_polling(dp, skip_updates=True, on_startup=on_startup) 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Loading token from .env 4 | TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") 5 | 6 | # Influx for metrics 7 | INFLUX_HOST = os.getenv("INFLUX_HOST", None) 8 | INFLUX_PORT = os.getenv("INFLUX_PORT", None) 9 | INFLUX_USERNAME = os.getenv("INFLUX_USERNAME", None) 10 | INFLUX_PASSWORD = os.getenv("INFLUX_PASSWORD", None) 11 | INFLUX_DB = os.getenv("INFLUX_DB", None) 12 | 13 | # Notifications 14 | NOTIFICATION_BOT_TOKEN = os.getenv("NOTIFICATION_BOT_TOKEN") 15 | NOTIFICATION_USERS = os.getenv("NOTIFICATION_USERS", "").split(",") 16 | # Send all checks result to NOTIFICATION_USERS 17 | NOTIFY_CHECKS = True 18 | 19 | # Mysql params 20 | MYSQL_HOST = os.getenv("MYSQL_HOST", None) # if none, use sqlite db 21 | MYSQL_USER = os.getenv("MYSQL_USER") 22 | MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD") 23 | MYSQL_PORT = os.getenv("MYSQL_PORT", 3306) 24 | MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "unicheckbot") 25 | 26 | # Sentry 27 | SENTRY_DSN = os.getenv("SENTRY_DSN") 28 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import default 2 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import time 3 | from typing import Any, List, Tuple 4 | from uuid import uuid4 5 | 6 | from aiogram.bot import Bot 7 | from aiogram.types import Message 8 | from core.coretypes import APINodeInfo 9 | from httpx import Response 10 | from loguru import logger 11 | 12 | from ..middlewares.throttling import rate_limit 13 | from ..nodes import nodes as all_nodes 14 | from .errors import InvalidPort, LocalhostForbidden, NotEnoughArgs 15 | from .helpers import send_api_requests, send_message_to_admins 16 | from .validators import BaseValidator, LocalhostValidator 17 | from ..config import NOTIFY_CHECKS 18 | 19 | header = "Отчет о проверке хоста:" \ 20 | "\n\n— Хост: {0}"\ 21 | "\n— Дата проверки: {1} (MSK)" # TODO: Get timezone 22 | 23 | 24 | class SimpleCommandHandler: 25 | help_message = "Set help message in class!" 26 | localhost_forbidden_message = "❗ Локальные адреса запрещены" 27 | validators: List[BaseValidator] = [LocalhostValidator()] 28 | 29 | @rate_limit 30 | async def handler(self, message: Message): 31 | pass 32 | 33 | def process_args(self, text: str) -> list: 34 | raise NotImplemented 35 | 36 | def validate_target(self, target: str): 37 | for validator in self.validators: 38 | validator.validate(target) 39 | 40 | async def prepare_message(self, *args) -> str: 41 | raise NotImplemented 42 | 43 | 44 | class CheckerBaseHandler(SimpleCommandHandler): 45 | invalid_port_message = "Invalid port!" 46 | header_message = header 47 | api_endpoint = "Set api endpoint in class!" 48 | 49 | def __init__(self): 50 | pass 51 | 52 | async def check(self, msg: Message, data: dict): 53 | # TODO: start check and end check metrics with ident, chat_id and api_endpoint 54 | ts = time() 55 | ident = uuid4().hex 56 | # refactoring goes brr 57 | chat_id = msg.chat.id 58 | bot = msg.bot 59 | logger.info(f"User {chat_id} started check {ident}") 60 | # format header 61 | rsp_msg = await bot.send_message( 62 | chat_id, 63 | header.format(data.get('target_fq'), datetime.now().strftime("%d.%m.%y в %H:%M")) 64 | ) 65 | iter_keys = 1 # because I can't use enumerate 66 | # using generators for magic... 67 | async for res in send_api_requests(self.api_endpoint, data, all_nodes): 68 | await bot.send_chat_action(chat_id, 'typing') 69 | if res.status_code == 500: 70 | rsp_msg = await rsp_msg.edit_text(rsp_msg.text + f"\n\n{iter_keys}. ❌️ Результат операции не доступен.") 71 | else: 72 | node_formatted_response = await self.prepare_message(res) 73 | rsp_msg = await rsp_msg.edit_text(rsp_msg.text + f"\n\n{iter_keys}. {node_formatted_response}") 74 | iter_keys = iter_keys + 1 75 | 76 | if NOTIFY_CHECKS: 77 | notify_text = f"**User {msg.from_user.full_name} (@{msg.from_user.username}) ({chat_id}) issued check: " \ 78 | f"{self.api_endpoint} for {data['target_fq']}**\n\n" \ 79 | f"```\n{rsp_msg.text}\n```" 80 | await send_message_to_admins(notify_text) 81 | 82 | logger.info(f"User {chat_id} ended check {ident}") 83 | await rsp_msg.edit_text(rsp_msg.text + f"\n\nПроверка завершена❗") 84 | te = time() 85 | logger.info(f"func {__name__} took {te - ts} sec") 86 | 87 | async def message_std_vals(self, res: Response) -> Tuple[str, Any]: 88 | node = APINodeInfo(**res.json().get("node", None)) 89 | message = f"{node.location}:\n" 90 | status = res.json().get("status", None) 91 | return message, status 92 | 93 | 94 | class CheckerTargetPortHandler(CheckerBaseHandler): 95 | 96 | @rate_limit 97 | async def handler(self, message: Message): 98 | """This hanler can be used if you need target port args""" 99 | try: 100 | args = self.process_args(message.text) 101 | except NotEnoughArgs: 102 | logger.info(f"User {message.from_user.id} got NotEnoughArgs error") 103 | return await message.answer(self.help_message, parse_mode="Markdown") 104 | except InvalidPort: 105 | logger.info(f"User {message.from_user.id} got InvalidPort error") 106 | return await message.answer(self.invalid_port_message, parse_mode="Markdown") 107 | try: 108 | self.validate_target(args[0]) 109 | except ValueError: # For ip check 110 | pass 111 | except LocalhostForbidden: 112 | logger.info(f"User {message.from_user.id} got LocalhostForbidden error") 113 | return await message.answer(self.localhost_forbidden_message, parse_mode="Markdown") 114 | await self.check( 115 | message, 116 | dict(target=args[0], port=args[1], target_fq=f"{args[0]}:{args[1]}") 117 | ) 118 | 119 | 120 | def parse_host_port(text: str, default_port: int) -> Tuple[str, int]: 121 | """Parse host:port 122 | """ 123 | text = text.strip() 124 | port = default_port 125 | host = text 126 | if ":" in text: 127 | host, port = text.rsplit(":", 1) 128 | elif " " in text: 129 | host, port = text.rsplit(" ", 1) 130 | 131 | try: 132 | port = int(port) 133 | # !Important: Don't check range if port == default_port! 134 | assert port == default_port or port in range(1, 65_536) 135 | except (ValueError, AssertionError): 136 | raise InvalidPort(port) 137 | 138 | return (host, port) 139 | 140 | 141 | def process_args_for_host_port(text: str, default_port: int) -> Tuple[str, int]: 142 | """Parse target from command 143 | """ 144 | args = text.split(' ', 1) 145 | if len(args) != 2: 146 | raise NotEnoughArgs() 147 | target = args[1] 148 | return parse_host_port(target, default_port) 149 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .icmp import ICMPCheckerHandler 4 | from .ipcalc import IPCalcCommandHandler 5 | from .minecraft import MinecraftCheckerHandler 6 | from .start import start_cmd 7 | from .tcp import TCPCheckerHandler 8 | from .web import WebCheckerHandler 9 | from .whois import WhoisCommandHandler 10 | 11 | 12 | def setup(dp: Dispatcher): 13 | dp.register_message_handler(start_cmd, is_forwarded=False, commands=['start', 'help']) 14 | dp.register_message_handler(WebCheckerHandler().handler, is_forwarded=False, commands=['web', 'http']) 15 | dp.register_message_handler(WhoisCommandHandler().handler, is_forwarded=False, commands=['whois']) 16 | dp.register_message_handler(ICMPCheckerHandler().handler, is_forwarded=False, commands=['icmp', 'ping']) 17 | dp.register_message_handler(TCPCheckerHandler().handler, is_forwarded=False, commands=['tcp']) 18 | dp.register_message_handler(MinecraftCheckerHandler().handler, is_forwarded=False, commands=['minecraft', 'mc']) 19 | dp.register_message_handler(IPCalcCommandHandler().handler, is_forwarded=False, commands=['ipcalc']) 20 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/icmp.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import Message 2 | from core.coretypes import ErrorPayload, ICMPCheckerResponse, ResponseStatus 3 | from httpx import Response 4 | 5 | from ...middlewares.throttling import rate_limit 6 | from ..base import CheckerBaseHandler, LocalhostForbidden, NotEnoughArgs 7 | from ..metrics import push_status_metric 8 | 9 | icmp_help_message = """ 10 | ❓ Производит проверку хоста по протоколу ICMP. 11 | 12 | Использование: 13 | `/ping ` 14 | """ 15 | 16 | 17 | class ICMPCheckerHandler(CheckerBaseHandler): 18 | help_message = icmp_help_message 19 | api_endpoint = "icmp" 20 | 21 | def __init__(self): 22 | super(ICMPCheckerHandler, self).__init__() 23 | 24 | @rate_limit 25 | async def handler(self, message: Message): 26 | try: 27 | args = self.process_args(message.text) 28 | except NotEnoughArgs: 29 | return await message.answer(icmp_help_message, parse_mode="Markdown") 30 | except LocalhostForbidden: 31 | return await message.answer(self.localhost_forbidden_message, parse_mode="Markdown") 32 | else: 33 | await self.check(message, dict(target=args[0], target_fq=args[0])) 34 | 35 | def process_args(self, text: str) -> list: 36 | args = text.split() 37 | if len(args) == 1: 38 | raise NotEnoughArgs() 39 | if len(args) >= 2: 40 | target = args[1] 41 | self.validate_target(target) 42 | return [target] 43 | 44 | async def prepare_message(self, res: Response): 45 | message, status = await self.message_std_vals(res) 46 | if status == ResponseStatus.OK: 47 | payload = ICMPCheckerResponse(**res.json().get("payload")) 48 | message += f"✅ {payload.min_rtt}/{payload.max_rtt}/{payload.avg_rtt} " \ 49 | f"⬆{payload.packets_sent} ️⬇️{payload.packets_received} Loss: {payload.loss}" 50 | if status == ResponseStatus.ERROR: 51 | payload = ErrorPayload(**res.json().get("payload")) 52 | message += f"❌️ {payload.message}" 53 | await push_status_metric(status, self.api_endpoint) 54 | return message 55 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/ipcalc.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from typing import Union 3 | 4 | from aiogram.types import Message 5 | 6 | from ...middlewares.throttling import rate_limit 7 | from ..base import NotEnoughArgs, SimpleCommandHandler 8 | 9 | ipcalc_help_message = """ 10 | ❓ Калькулятор IP подсетей. 11 | 12 | Использование: 13 | `/ipcalc ` 14 | `/ipcalc ` - автоматически выставит маску 32 15 | """ 16 | 17 | 18 | class IPCalcCommandHandler(SimpleCommandHandler): 19 | 20 | help_message = ipcalc_help_message 21 | 22 | def __init__(self): 23 | super().__init__() 24 | 25 | @rate_limit 26 | async def handler(self, message: Message): 27 | try: 28 | args = self.process_args(message.text) 29 | network = ipaddress.ip_network(args[1], False) 30 | except NotEnoughArgs: 31 | await message.answer(self.help_message, parse_mode='Markdown') 32 | except ValueError: 33 | await message.answer(self.help_message, parse_mode='Markdown') 34 | else: 35 | msg = await self.prepare_message(network) 36 | await message.answer(msg) 37 | 38 | def process_args(self, text: str) -> list: 39 | args = text.split() 40 | if len(args) == 1: 41 | raise NotEnoughArgs 42 | return args 43 | 44 | async def prepare_message(self, ip_net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str: 45 | 46 | work_adresses = ip_net.num_addresses - 2 47 | first_ip = "Нет доступных адресов." 48 | last_ip = first_ip 49 | if ip_net.num_addresses <= 2: 50 | work_adresses = 0 51 | else: 52 | first_ip = list(ip_net.hosts())[0] 53 | last_ip = list(ip_net.hosts())[-1] 54 | 55 | return f"📱 IP подсети: {ip_net.with_prefixlen}\n" \ 56 | f"📌 Маска подсети: {ip_net.netmask}\n" \ 57 | f"📌 Обратная маска: {ip_net.hostmask}\n" \ 58 | f"📌 Широковещательный адрес: {ip_net.broadcast_address}\n" \ 59 | f"📌 Доступные адреса: {ip_net.num_addresses}\n" \ 60 | f"📌 Рабочие адреса: {work_adresses}\n\n" \ 61 | f"🔼 IP первого хоста: {first_ip}\n" \ 62 | f"🔽 IP последнего хоста: {last_ip}" 63 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/minecraft.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import ErrorPayload, MinecraftResponse, ResponseStatus 2 | from httpx import Response 3 | 4 | from ..base import CheckerTargetPortHandler, process_args_for_host_port 5 | from ..metrics import push_status_metric 6 | 7 | minecraft_help_message = """ 8 | ❓ Получает статистику о Minecraft сервере 9 | 10 | Использование: 11 | `/mc ` 12 | `/mc :` 13 | `/mc ` - автоматически выставит порт 25565 14 | """ 15 | 16 | 17 | invalid_port = """❗Неправильный порт. Напишите /mc чтобы увидеть справку к данному способу проверки.""" 18 | 19 | 20 | class MinecraftCheckerHandler(CheckerTargetPortHandler): 21 | help_message = minecraft_help_message 22 | api_endpoint = "minecraft" 23 | 24 | def __init__(self): 25 | super().__init__() 26 | 27 | def process_args(self, text: str) -> list: 28 | return process_args_for_host_port(text, 25565) 29 | 30 | async def prepare_message(self, res: Response): 31 | message, status = await self.message_std_vals(res) 32 | if status == ResponseStatus.OK: 33 | payload = MinecraftResponse(**res.json().get("payload")) 34 | message += f"✅ 👤{payload.online}/{payload.max_players} 📶{payload.latency}ms" 35 | if status == ResponseStatus.ERROR: 36 | payload = ErrorPayload(**res.json().get("payload")) 37 | message += f"❌️ {payload.message}" 38 | await push_status_metric(status, self.api_endpoint) 39 | return message 40 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/start.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import Message 2 | 3 | from ...middlewares.throttling import rate_limit 4 | from ...middlewares.userdata import userdata_required 5 | from ...models.user import User 6 | 7 | start_message = """ 8 | 9 | Привет, *%name%*! 10 | 11 | Я @UniCheckBot — бот, позволяющий получить краткую сводку о веб-сервисе. Могу пропинговать, проверить порты, получить информацию о Minecraft-сервере, IP или доменном имени. 12 | 13 | Вот список доступных команд: 14 | 15 | 📌 `/ping ` — пропинговать сервер/сайт 16 | 📌 `/ipcalc ` — посчитать подсеть IP-адресов 17 | 18 | 📌 `/tcp ` — проверить TCP-порт 19 | 20 | 📌 `/web ` — проверить сайт по HTTP с возвратом ответа 21 | 📌 `/whois ` — узнать владельца IP/домена 22 | 23 | 📌 `/mc ` — проверить сервер Minecraft 24 | 25 | Полезные ссылки: 26 | 27 | 🚩 [Этот бот с открытым с исходным кодом](https://github.com/catspace-dev/unicheckbot) 28 | 🚩 [Помогите улучшить бота](https://github.com/catspace-dev/unicheckbot/issues) или [расскажите об ошибке](https://github.com/catspace-dev/unicheckbot/issues) 29 | 30 | Разработчик: [kiriharu](https://t.me/kiriharu) 31 | При поддержке: [SpaceCore.pro](https://spacecore.pro/) 32 | 33 | """ 34 | 35 | 36 | @userdata_required 37 | @rate_limit 38 | async def start_cmd(msg: Message, user: User): 39 | await msg.answer( 40 | start_message.replace("%name%", msg.from_user.full_name), 41 | parse_mode='markdown', 42 | disable_web_page_preview=True 43 | ) 44 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/tcp.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from aiogram.types import Message 4 | from core.coretypes import ErrorPayload, PortResponse, ResponseStatus 5 | from httpx import Response 6 | 7 | from ...middlewares.throttling import rate_limit 8 | from ..base import (CheckerTargetPortHandler, InvalidPort, NotEnoughArgs, 9 | parse_host_port) 10 | from ..metrics import push_status_metric 11 | 12 | tcp_help_message = """ 13 | ❓ Производит проверку TCP порта, открыт ли он или нет 14 | 15 | Использование: 16 | `/tcp ` 17 | `/tcp :` 18 | """ 19 | 20 | invalid_port = """❗Неправильный порт. Напишите /tcp чтобы увидеть справку к данному способу проверки.""" 21 | 22 | 23 | class TCPCheckerHandler(CheckerTargetPortHandler): 24 | help_message = tcp_help_message 25 | api_endpoint = "tcp_port" 26 | 27 | def __init__(self): 28 | super().__init__() 29 | 30 | @rate_limit 31 | async def handler(self, message: Message): 32 | await super(TCPCheckerHandler, self).handler(message) 33 | 34 | def process_args(self, text: str) -> Tuple[str, int]: 35 | args = text.split(' ', 1) 36 | if len(args) != 2: 37 | raise NotEnoughArgs() 38 | host = args[1] 39 | host, port = parse_host_port(host, -1) 40 | if port == -1: 41 | raise NotEnoughArgs() 42 | return (host, port) 43 | 44 | async def prepare_message(self, res: Response): 45 | message, status = await self.message_std_vals(res) 46 | if status == ResponseStatus.OK: 47 | payload = PortResponse(**res.json().get("payload")) 48 | if payload.open: 49 | message += "✅ Порт ОТКРЫТ" 50 | else: 51 | message += "❌️ Порт ЗАКРЫТ" 52 | if status == ResponseStatus.ERROR: 53 | payload = ErrorPayload(**res.json().get("payload")) 54 | message += f"❌️ {payload.message}" 55 | await push_status_metric(status, self.api_endpoint) 56 | return message 57 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/web.py: -------------------------------------------------------------------------------- 1 | from core.coretypes import (HTTP_EMOJI, ErrorPayload, HttpCheckerResponse, 2 | ResponseStatus) 3 | from httpx import Response 4 | 5 | from ..base import CheckerTargetPortHandler, process_args_for_host_port 6 | from ..metrics import push_status_metric 7 | 8 | web_help_message = """ 9 | ❓ Производит проверку хоста по протоколу HTTP. 10 | 11 | Использование: 12 | `/web ` 13 | `/web :` 14 | `/web ` - автоматически выставит 80 порт 15 | """ 16 | 17 | invalid_port = """❗Неправильный порт. Напишите /web чтобы увидеть справку к данному способу проверки.""" 18 | 19 | 20 | class WebCheckerHandler(CheckerTargetPortHandler): 21 | help_message = web_help_message 22 | api_endpoint = "http" 23 | 24 | def __init__(self): 25 | super().__init__() 26 | 27 | def process_args(self, text: str) -> list: 28 | return process_args_for_host_port(text, 80) 29 | 30 | async def prepare_message(self, res: Response): 31 | message, status = await self.message_std_vals(res) 32 | if status == ResponseStatus.OK: 33 | payload = HttpCheckerResponse(**res.json().get("payload")) 34 | message += f"{HTTP_EMOJI.get(payload.status_code // 100, '')} " \ 35 | f"{payload.status_code}, ⏰ {payload.time * 1000:.2f}ms" 36 | if status == ResponseStatus.ERROR: 37 | payload = ErrorPayload(**res.json().get("payload")) 38 | message += f"❌️ {payload.message}" 39 | await push_status_metric(status, self.api_endpoint) 40 | return message 41 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/default/whois.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from aiogram.types import Message 5 | from whois_vu.api import WhoisSource 6 | from whois_vu.errors import IncorrectZone, QueryNotMatchRegexp 7 | from sentry_sdk import capture_exception 8 | 9 | from whois import parser, whois 10 | 11 | from ...middlewares.throttling import rate_limit 12 | from ..base import SimpleCommandHandler 13 | from ..errors import LocalhostForbidden, NotEnoughArgs 14 | from ..whois_zones import ZONES 15 | 16 | whois_help_message = """ 17 | ❓ Вернёт информацию о домене. 18 | 19 | Использование: `/whois <домен>` 20 | """ 21 | 22 | no_domain_text = """ 23 | ❗Не указан домен или указан неверный/несуществующий домен. 24 | 25 | Напишите /whois чтобы посмотреть справку. 26 | """ 27 | 28 | incorrect_domain = "❗ Домен {domain} не поддерживается в текущей реализации /whois или его попросту не " \ 29 | "существует.\n\n" \ 30 | "📌 Если вы считаете что это какая-то ошибка, " \ 31 | "то вы можете рассказать " \ 32 | "нам о ней удобным для вас способом. Контакты указаны в /start." 33 | 34 | 35 | @dataclass 36 | class DomainAttrClass: 37 | icon: str 38 | name: str 39 | attr: str 40 | 41 | 42 | # DOMAIN_ATTR_CLASSES order matters! 43 | DOMAIN_ATTR_CLASSES = [ 44 | DomainAttrClass("👤", "Регистратор", "registrar"), 45 | DomainAttrClass("📅", "Дата создания", "creation_date"), 46 | DomainAttrClass("📅", "Дата окончания", "expiration_date"), 47 | DomainAttrClass("📖", "Адрес", "address"), 48 | DomainAttrClass("🏘", "Город", "city"), 49 | DomainAttrClass("🏘", "Страна", "country"), 50 | DomainAttrClass("💬", "Имя", "name"), 51 | DomainAttrClass("💼", "Организация", "org"), 52 | DomainAttrClass("💬", "Zipcode", "zipcode"), 53 | DomainAttrClass("✉", "Почта", "emails"), 54 | DomainAttrClass("📌", "NS", "name_servers"), 55 | DomainAttrClass("🔐", "DNSSec", "dnssec"), 56 | ] 57 | 58 | 59 | def whois_request(domain: str) -> parser.WhoisEntry: 60 | domain_info = whois(domain) 61 | if domain_info.get("domain_name") is None: 62 | splitted = domain.split(".") 63 | ws = WhoisSource().get(domain) 64 | if zone_class := ZONES.get(splitted[-1], None): 65 | domain_info = zone_class(domain, ws.whois) 66 | else: 67 | domain_info = parser.WhoisEntry.load(domain, ws.whois) 68 | return domain_info 69 | 70 | 71 | def create_whois_message(domain: str) -> str: 72 | try: 73 | domain_info = whois_request(domain) 74 | except parser.PywhoisError: 75 | return f"❗ Домен {domain} свободен или не был найден." 76 | except IncorrectZone as e: 77 | capture_exception(e) 78 | return incorrect_domain.format(domain=domain) 79 | except QueryNotMatchRegexp: 80 | return incorrect_domain.format(domain=domain) 81 | domain_name = domain_info.get("domain_name") 82 | if not domain_name: 83 | return incorrect_domain.format(domain=domain) 84 | if isinstance(domain_name, list): 85 | domain_name = domain_name[0] 86 | message = f"\n📝 Информация о домене {domain_name.lower()}:" 87 | 88 | for i, domain_attr in enumerate(DOMAIN_ATTR_CLASSES): 89 | # for pretty printing, DOMAIN_ATTR_CLASSES order matters! 90 | if i in [2, 10]: 91 | message += "\n" 92 | resp = format_domain_item( 93 | domain_attr.icon, domain_attr.name, domain_info.get(domain_attr.attr) 94 | ) 95 | if resp: 96 | message += resp 97 | 98 | return message 99 | 100 | 101 | def format_domain_item(icon, item_name, items) -> Optional[str]: 102 | if not items: 103 | return 104 | if isinstance(items, list): 105 | items = map(str, items) # fix datetime bug 106 | message = f"\n{icon} {item_name}:\n" 107 | message += str.join("\n", [f" * {ns}" for ns in list(set(map(str.lower, items)))]) 108 | else: 109 | message = f"\n{icon} {item_name}: {items}" 110 | return message 111 | 112 | 113 | class WhoisCommandHandler(SimpleCommandHandler): 114 | help_message = whois_help_message 115 | 116 | def __init__(self): 117 | super().__init__() 118 | 119 | @rate_limit 120 | async def handler(self, message: Message): 121 | try: 122 | args = self.process_args(message.text) 123 | except NotEnoughArgs: 124 | await message.answer(no_domain_text, parse_mode='Markdown') 125 | except LocalhostForbidden: 126 | await message.answer(self.localhost_forbidden_message, parse_mode='Markdown') 127 | else: 128 | await message.answer(create_whois_message(args[0]), parse_mode='html') 129 | 130 | def process_args(self, text: str) -> list: 131 | args = text.split() 132 | if len(args) == 1: 133 | raise NotEnoughArgs 134 | if len(args) >= 2: 135 | host = args[1] 136 | self.validate_target(host) 137 | return [host] # only domain name 138 | 139 | async def prepare_message(self) -> str: 140 | pass 141 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/errors.py: -------------------------------------------------------------------------------- 1 | class NotEnoughArgs(Exception): 2 | pass 3 | 4 | 5 | class InvalidPort(Exception): 6 | pass 7 | 8 | 9 | class LocalhostForbidden(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/helpers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from traceback import format_exc 3 | from typing import List 4 | 5 | from aiogram.bot import Bot 6 | from core.coretypes import APINode 7 | from httpx import AsyncClient, Response, Timeout 8 | from loguru import logger 9 | from sentry_sdk import capture_exception 10 | 11 | from ..config import NOTIFICATION_BOT_TOKEN, NOTIFICATION_USERS 12 | from .metrics import push_api_request_status 13 | 14 | 15 | async def send_api_request(client: AsyncClient, endpoint: str, data: dict, node: APINode): 16 | try: 17 | data['token'] = node.token 18 | result = await client.get( 19 | f"{node.address}/{endpoint}", params=data 20 | ) 21 | except Exception as e: 22 | exc_id = capture_exception(e) 23 | # Remove token from log data 24 | data.pop('token', None) 25 | logger.error(f"Node {node.address} got Error. Data: {data}. Endpoint: {endpoint}. Full exception: {e}") 26 | result = Response(500) 27 | if exc_id: 28 | await send_message_to_admins(f"Node {node.address} got error: `{e}`. \n" 29 | f"Data: `{data}`, Endpoint: `{endpoint}`\n" 30 | f"Exc sentry: {exc_id}") 31 | else: 32 | await send_message_to_admins(f"Node {node.address} got error: `{e}`. \n" 33 | f"Data: `{data}`, Endpoint: `{endpoint}`\n" 34 | f"Full exception: ```{format_exc()}```") 35 | await push_api_request_status( 36 | result.status_code, 37 | endpoint 38 | ) 39 | return result 40 | 41 | 42 | async def send_api_requests(endpoint: str, data: dict, nodes: List[APINode]): 43 | async with AsyncClient(timeout=Timeout(timeout=100.0)) as client: 44 | tasks = [send_api_request(client, endpoint, data, node) for node in nodes] 45 | for completed in asyncio.as_completed(tasks): 46 | res = await completed 47 | yield res 48 | 49 | 50 | async def send_message_to_admins(message: str) -> None: 51 | if NOTIFICATION_BOT_TOKEN: 52 | bot = Bot(token=NOTIFICATION_BOT_TOKEN) 53 | for user in NOTIFICATION_USERS: 54 | logger.info(f"Sended notification to {user}") 55 | await bot.send_message(user, message, parse_mode='Markdown') 56 | await bot.close() 57 | else: 58 | logger.warning(f"Notificator bot token not setted. Skipping send notifications to admin") 59 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/metrics.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from aioinflux import InfluxDBClient 4 | 5 | from ..config import (INFLUX_DB, INFLUX_HOST, INFLUX_PASSWORD, INFLUX_PORT, 6 | INFLUX_USERNAME) 7 | 8 | 9 | async def push_metric(measurement, tags: Dict, fields: Dict): 10 | if INFLUX_HOST is None: 11 | pass 12 | else: 13 | point = { 14 | 'measurement': measurement, 15 | 'tags': tags, 16 | 'fields': fields 17 | } 18 | async with InfluxDBClient( 19 | host=INFLUX_HOST, 20 | port=INFLUX_PORT, 21 | username=INFLUX_USERNAME, 22 | password=INFLUX_PASSWORD, 23 | db=INFLUX_DB, 24 | mode='async' 25 | ) as client: 26 | await client.write(point) 27 | 28 | 29 | async def push_api_request_status(status_code: int, endpoint: str): 30 | await push_metric( 31 | measurement="bot_api_request", 32 | fields=dict( 33 | value=1, 34 | ), 35 | tags=dict( 36 | status=status_code, 37 | endpoint=endpoint 38 | ) 39 | ) 40 | 41 | 42 | async def push_status_metric(status, api_endpoint): 43 | await push_metric( 44 | measurement="bot_prepared_messages", 45 | fields=dict( 46 | value=1, 47 | ), 48 | tags=dict( 49 | rsp_status=status, 50 | api_endpoint=api_endpoint 51 | ) 52 | ) 53 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/validators.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from ipaddress import ip_address 3 | 4 | from .errors import LocalhostForbidden 5 | 6 | 7 | class BaseValidator: 8 | 9 | def __init__(self): 10 | pass 11 | 12 | def validate(self, target: str, **kwargs): 13 | pass 14 | 15 | 16 | class LocalhostValidator(BaseValidator): 17 | 18 | def validate(self, target: str, **kwargs): 19 | if target == "localhost": 20 | raise LocalhostForbidden 21 | with suppress(ValueError): 22 | ip_addr = ip_address(target) 23 | if any( 24 | [ip_addr.is_loopback, 25 | ip_addr.is_private, 26 | ip_addr.is_multicast, 27 | ip_addr.is_link_local, 28 | ip_addr.is_unspecified] 29 | ): 30 | raise LocalhostForbidden 31 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/handlers/whois_zones.py: -------------------------------------------------------------------------------- 1 | from whois.parser import WhoisEntry, PywhoisError, EMAIL_REGEX 2 | 3 | 4 | class WhoisCf(WhoisEntry): 5 | """Whois parser for .cf domains 6 | """ 7 | regex = { 8 | 'domain_name': 'Domain name:\n*(.+)\n', 9 | 'org': 'Organisation:\n *(.+)', 10 | 'emails': EMAIL_REGEX, 11 | } 12 | 13 | def __init__(self, domain, text): 14 | if 'The domain you requested is not known in Freenoms database' in text: 15 | raise PywhoisError(text) 16 | else: 17 | WhoisEntry.__init__(self, domain, text, self.regex) 18 | 19 | 20 | ZONES = { 21 | "cf": WhoisCf 22 | } -------------------------------------------------------------------------------- /apps/tgbot/tgbot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .logging import LoggingMiddleware 2 | from .throttling import ThrottlingMiddleware 3 | from .userdata import UserMiddleware 4 | from .write_command_metric import WriteCommandMetric 5 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/middlewares/logging.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.middlewares import BaseMiddleware 2 | from aiogram.types import Message 3 | from loguru import logger 4 | 5 | 6 | class LoggingMiddleware(BaseMiddleware): 7 | 8 | def __init__(self): 9 | super().__init__() 10 | 11 | async def on_process_message(self, message: Message, data: dict): 12 | logger.info(f"User {message.from_user.id} issued command: {message.text}") 13 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/middlewares/throttling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Dispatcher, types 4 | from aiogram.dispatcher import DEFAULT_RATE_LIMIT 5 | from aiogram.dispatcher.handler import CancelHandler, current_handler 6 | from aiogram.dispatcher.middlewares import BaseMiddleware 7 | from aiogram.utils.exceptions import Throttled 8 | 9 | 10 | def rate_limit(func): 11 | setattr(func, 'throttling_rate_limit', 2) 12 | setattr(func, 'throttling_key', 'message') 13 | return func 14 | 15 | 16 | class ThrottlingMiddleware(BaseMiddleware): 17 | """ 18 | Simple middleware 19 | TODO: Rewrite 20 | From https://docs.aiogram.dev/en/latest/examples/middleware_and_antiflood.html 21 | """ 22 | 23 | def __init__(self, limit=DEFAULT_RATE_LIMIT, key_prefix='antiflood_'): 24 | self.rate_limit = limit 25 | self.prefix = key_prefix 26 | super().__init__() 27 | 28 | async def on_process_message(self, message: types.Message, data: dict): 29 | handler = current_handler.get() 30 | 31 | dispatcher = Dispatcher.get_current() 32 | if handler: 33 | limit = getattr(handler, 'throttling_rate_limit', self.rate_limit) 34 | key = getattr(handler, 'throttling_key', f"{self.prefix}_{handler.__name__}") 35 | else: 36 | limit = self.rate_limit 37 | key = f"{self.prefix}_message" 38 | 39 | try: 40 | await dispatcher.throttle(key, rate=limit) 41 | except Throttled as t: 42 | await self.message_throttled(message, t) 43 | raise CancelHandler() 44 | 45 | async def message_throttled(self, message: types.Message, throttled: Throttled): 46 | """ 47 | Notify user only on first exceed and notify about unlocking only on last exceed 48 | 49 | :param message: 50 | :param throttled: 51 | """ 52 | handler = current_handler.get() 53 | dispatcher = Dispatcher.get_current() 54 | if handler: 55 | key = getattr(handler, 'throttling_key', f"{self.prefix}_{handler.__name__}") 56 | else: 57 | key = f"{self.prefix}_message" 58 | 59 | # Calculate how many time is left till the block ends 60 | delta = throttled.rate - throttled.delta 61 | 62 | # Prevent flooding 63 | if throttled.exceeded_count <= 2: 64 | await message.reply('❗️Слишком много запросов. ' 65 | 'Подождите еще несколько секунд перед отправкой следующего сообщения.' 66 | '\nВ целях предотвращения флуда, бот перестанет отвечать на ваши сообщения ' 67 | 'на некоторое время.') 68 | 69 | # Sleep. 70 | await asyncio.sleep(delta) 71 | 72 | # Check lock status 73 | thr = await dispatcher.check_key(key) 74 | 75 | # If current message is not last with current key - do not send message 76 | if thr.exceeded_count == throttled.exceeded_count: 77 | await message.reply('Unlocked.') 78 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/middlewares/userdata.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.handler import current_handler 2 | from aiogram.dispatcher.middlewares import BaseMiddleware 3 | from aiogram.types import CallbackQuery, Message 4 | 5 | from ..models import User 6 | 7 | 8 | def userdata_required(func): 9 | """Setting login_required to function""" 10 | setattr(func, 'userdata_required', True) 11 | return func 12 | 13 | # I think, now it useless 14 | 15 | 16 | class UserMiddleware(BaseMiddleware): 17 | 18 | def __init__(self): 19 | super(UserMiddleware, self).__init__() 20 | 21 | @staticmethod 22 | async def get_userdata(telegram_id: int) -> User: 23 | handler = current_handler.get() 24 | if handler: 25 | attr = getattr(handler, 'userdata_required', False) 26 | if attr: 27 | # Setting user 28 | user, _ = await User.get_or_create(telegram_id=telegram_id) 29 | return user 30 | 31 | async def on_process_message(self, message: Message, data: dict): 32 | data['user'] = await self.get_userdata(message.from_user.id) 33 | 34 | async def on_process_callback_query(self, callback_query: CallbackQuery, data: dict): 35 | data['user'] = await self.get_userdata(callback_query.from_user.id) 36 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/middlewares/write_command_metric.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.middlewares import BaseMiddleware 2 | from aiogram.types import Message 3 | 4 | from ..handlers.metrics import push_metric 5 | from ..models import User, UserCheckRequests 6 | 7 | 8 | class WriteCommandMetric(BaseMiddleware): 9 | 10 | def __init__(self): 11 | super().__init__() 12 | 13 | async def on_process_message(self, message: Message, data: dict): 14 | # commands to DB 15 | user, _ = await User.get_or_create(telegram_id=message.from_user.id) 16 | await UserCheckRequests.create(user=user, request=message.text) 17 | 18 | # metrics to influxdb 19 | await push_metric( 20 | measurement="bot_processed_messages", 21 | fields=dict( 22 | telegram_id=message.from_user.id, 23 | full_command=message.text, 24 | value=1, 25 | ), 26 | tags=dict( 27 | command=message.text.split()[0], 28 | type="command" 29 | ) 30 | ) 31 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import * 2 | from .requests import * 3 | 4 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/models/requests.py: -------------------------------------------------------------------------------- 1 | from tortoise import Model, fields 2 | 3 | 4 | class UserCheckRequests(Model): 5 | id = fields.IntField(pk=True) 6 | user = fields.ForeignKeyField('models.User', related_name='requests') 7 | request = fields.CharField(max_length=255) 8 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/models/user.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields, Model 2 | 3 | 4 | class User(Model): 5 | telegram_id = fields.IntField(pk=True) 6 | created_at = fields.DatetimeField(auto_now_add=True) 7 | updated_at = fields.DatetimeField(auto_now=True) 8 | -------------------------------------------------------------------------------- /apps/tgbot/tgbot/nodes.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from core.coretypes import APINode 4 | 5 | nodes: List[APINode] = [ 6 | APINode("http://localhost:8080", "CHANGE_TOKEN_BY_ENV"), 7 | APINode("http://localhost:8080", "CHANGE_TOKEN_BY_ENV"), 8 | APINode("http://localhost:8080", "CHANGE_TOKEN_BY_ENV"), 9 | ] 10 | -------------------------------------------------------------------------------- /docker-compose-api.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | api: 4 | build: 5 | context: ./apps 6 | dockerfile: Dockerfile-api 7 | env_file: 8 | - api.env 9 | ports: 10 | - ${APP_PORT}:${APP_PORT} 11 | -------------------------------------------------------------------------------- /docker-compose-tgbot.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | tgbot: 4 | build: 5 | context: ./apps 6 | dockerfile: Dockerfile-tgbot 7 | env_file: 8 | - tgbot.env 9 | depends_on: 10 | - db 11 | 12 | 13 | db: 14 | image: mariadb 15 | restart: always 16 | env_file: 17 | - tgbot.env 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "unicheckbot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["kiriharu "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8.2" 9 | 10 | [tool.poetry.dev-dependencies] 11 | pytest = "^6.2.2" 12 | flake8 = "^3.8.4" 13 | 14 | [build-system] 15 | requires = ["poetry-core>=1.0.0"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /tgbot.env: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN=change_bot_token 2 | 3 | INFLUX_DB=hostinfobotdb 4 | INFLUX_HOST=localhost 5 | INFLUX_PORT=8086 6 | INFLUX_PASSWORD=change_password 7 | INFLUX_USERNAME=hostinfobot 8 | 9 | NOTIFICATION_BOT_TOKEN=change_bot_token 10 | NOTIFICATION_USERS=12345,123456,1412321 11 | 12 | MYSQL_HOST=db 13 | MYSQL_USER=unicheck 14 | MYSQL_PASSWORD=unicheckbotpass 15 | MYSQL_DATABASE=unicheckbot 16 | MYSQL_ROOT_PASSWORD=rootpass 17 | --------------------------------------------------------------------------------