├── .dockerignore ├── .github └── workflows │ ├── build.yml │ ├── notify.yml │ └── pages.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── tgscraper ├── composer.json ├── composer.lock ├── docs └── schema.json ├── psalm.xml ├── src ├── Commands │ ├── Common.php │ ├── CreateStubsCommand.php │ ├── DumpSchemasCommand.php │ └── ExportSchemaCommand.php ├── Common │ ├── Encoder.php │ ├── OpenApiGenerator.php │ ├── SchemaExtractor.php │ └── StubCreator.php ├── Constants │ └── Versions.php ├── Parsers │ ├── Field.php │ ├── FieldDescription.php │ └── ObjectDescription.php └── TgScraper.php └── templates ├── openapi.json ├── postman.json └── responses.json /.dockerignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | /.idea/ 4 | /.git/ 5 | LICENSE 6 | README.md 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: Build package 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Docker image 16 | uses: docker/build-push-action@v1 17 | with: 18 | registry: ghcr.io 19 | username: sysbot-org 20 | password: ${{ secrets.PAT }} 21 | repository: sysbot-org/tgscraper 22 | tags: latest -------------------------------------------------------------------------------- /.github/workflows/notify.yml: -------------------------------------------------------------------------------- 1 | name: Notify 2 | on: 3 | push: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | notify: 9 | name: Notify via Telegram 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Send message to Telegram 13 | uses: Lukasss93/telegram-action@v2 14 | env: 15 | TELEGRAM_TOKEN: ${{ secrets.telegram_token }} 16 | TELEGRAM_CHAT: ${{ secrets.telegram_chat }} -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: GH Pages 2 | on: 3 | repository_dispatch: 4 | types: [on_update] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | pages: 9 | name: Build files for GH Pages 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Generate botapi.json 13 | uses: docker://ghcr.io/sysbot-org/tgscraper 14 | with: 15 | args: "app:export-schema --readable /github/workspace/botapi.json" 16 | - name: Generate botapi.yaml 17 | uses: docker://ghcr.io/sysbot-org/tgscraper 18 | with: 19 | args: "app:export-schema --yaml --readable /github/workspace/botapi.yaml" 20 | - name: Generate botapi_postman.json 21 | uses: docker://ghcr.io/sysbot-org/tgscraper 22 | with: 23 | args: "app:export-schema --postman --readable /github/workspace/botapi_postman.json" 24 | - name: Generate botapi_openapi.json 25 | uses: docker://ghcr.io/sysbot-org/tgscraper 26 | with: 27 | args: "app:export-schema --openapi --readable /github/workspace/botapi_openapi.json" 28 | - name: Generate botapi_openapi.yaml 29 | uses: docker://ghcr.io/sysbot-org/tgscraper 30 | with: 31 | args: "app:export-schema --yaml --openapi --readable /github/workspace/botapi_openapi.yaml" 32 | - name: Deploy 33 | uses: peaceiris/actions-gh-pages@v3 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: . 37 | destination_dir: schemas 38 | publish_branch: gh-pages 39 | cname: tgscraper.sys001.ml 40 | enable_jekyll: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | .phpunit.result.cache 4 | composer.phar -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## [Unreleased] 10 | 11 | ## [4.0.9] - 2024-07-02 12 | ### Added 13 | - Support for bot API from 7.1.0 to 7.6.0. 14 | 15 | ### Changed 16 | - Updated dependencies to their latest available version. 17 | 18 | ## [4.0.8] - 2024-01-05 19 | ### Added 20 | - Support for bot API 6.9.0 and 7.0.0. 21 | 22 | ## [4.0.7] - 2023-08-23 23 | ### Added 24 | - Support for bot API 6.8.0. 25 | 26 | ## [4.0.6] - 2023-03-30 27 | ### Added 28 | - Support for bot API 6.7.0. 29 | 30 | ## [4.0.5] - 2023-03-18 31 | ### Added 32 | - Support for bot API 6.4.0, 6.5.0 and 6.6.0. 33 | 34 | ## [4.0.4] - 2022-11-05 35 | ### Added 36 | - Support for bot API 6.3.0. 37 | 38 | ## [4.0.3] - 2022-08-12 39 | ### Added 40 | - Support for bot API 6.2.0. 41 | 42 | ### Changed 43 | - Updated dependencies to their latest available version. 44 | 45 | ## [4.0.2] - 2022-06-21 46 | ### Added 47 | - Support for bot API 6.1.0. 48 | 49 | ## [4.0.1] - 2022-04-16 50 | ### Added 51 | - Support for bot API 6.0.0. 52 | 53 | ## [4.0.0] - 2022-04-15 54 | ### Added 55 | - Support for bot API 5.6.0 and 5.7.0. 56 | - New `app:dump-schemas` command, used to generate schemas for all bot API versions. 57 | - New `default` property for fields, it contains their default values when they're unspecified. 58 | 59 | ### Changed 60 | - (**Breaking change**) Array format in custom schema has been changed from `Array` to `Array`. 61 | - Updated dependencies to their latest available version. 62 | 63 | ### Fixed 64 | - Increased speed dramatically by replacing the DOM parser, it's a lot faster now! 65 | - Huge refactoring, improved code quality and readability. 66 | - Some minor bug fixes. 67 | 68 | ## [3.0.3] - 2021-12-11 69 | ### Added 70 | - Support for bot API 5.5.0. 71 | 72 | ### Changed 73 | - Updated dependencies to their latest available version. 74 | 75 | ## [3.0.2] - 2021-11-08 76 | ### Added 77 | - Support for bot API 5.4.0. 78 | 79 | ### Fixed 80 | - Fixed GitHub action arguments for schemas generation. 81 | 82 | ## [3.0.1] - 2021-08-24 83 | ### Added 84 | - Initial support for publishing on GitHub Pages using a GitHub action. 85 | 86 | ### Fixed 87 | - Target folder can now be a nested folder. 88 | 89 | ## [3.0.0] - 2021-08-23 90 | ### Added 91 | - Support for OpenAPI schema: you can now generate code in any language! 92 | - Support for the new [sysbot/tgscraper-cache](https://github.com/Sysbot-org/tgscraper-cache) package: if installed, TGScraper will be much faster (there is no need to always fetch the live webpages)! 93 | - You can now validate a schema by using the `validateSchema` method, provided by the `TgScraper` class. 94 | - New `Versions::STABLE` constant: it will automatically return the latest stable version instead of the live version (useful for the cache package). 95 | - Added the JSON schema specification for the custom format provided by TGScraper. 96 | - Added this changelog. 97 | - Added a workflow to automatically generate the bot API schema every day. 98 | 99 | ### Changed 100 | - The `Generator` class has now been renamed `TgScraper`. 101 | - The `required` property in method fields has been replaced by the new `optional` property, for the sake of consistency. 102 | - You now need a schema in order to instantiate the `TgScraper` class (don't worry, you can use the new methods `TgScraper::fromUrl` and `TgScraper::fromVersion`). 103 | - The `Versions` class constants have been replaced with an actual version string. If you still need the URLs, use the new class constant `Versions::URLS`. 104 | - TGScraper will now only return arrays. If you still need JSON or YAML encoding, please use the new `Encoder` class. 105 | - Default inline value for YAML has been changed to 16. 106 | 107 | ### Fixed 108 | - Minor improvements to `StubCreator`. 109 | - Fixed an issue with the CLI where `autoload.php` couldn't be found. 110 | - When exporting the schema, the CLI will now make sure that the destination directory exists. 111 | 112 | ### Security 113 | - When a custom schema is used, only use `version`, `types` and `methods` fields. 114 | 115 | ## [2.1.0] - 2021-07-31 116 | ### Added 117 | - New repo workflows: automatic package build (and push to the GitHub registry), and automatic notifications via Telegram. 118 | - New `version` field for schemas: it contains the bot API version (if possible). 119 | - New `extended_by` field for types: if the current type is a parent one, it will contain its child types. 120 | 121 | ### Changed 122 | - Now all type stubs implement the base `TypeInterface` interface. 123 | - Children type stubs now extend their parent. 124 | - Optional fields are now actually optional in the Postman collection (previously, you had to manually disable optional ones). 125 | 126 | ### Fixed 127 | - Minor improvements to the schema extractor. 128 | 129 | ## [2.0.1] - 2021-07-24 130 | ### Changed 131 | - The README now includes many CLI examples. 132 | 133 | ### Fixed 134 | - The link for the Bot API 5.2.0 snapshot was broken, it's working now. 135 | 136 | ## [2.0.0] - 2021-07-24 137 | ### Added 138 | - Support for Postman collection: now you can generate a JSON to use in Postman! 139 | - New class for the URLs of various bot API snapshots: `TgScraper\Constants\Versions`. 140 | 141 | ### Changed 142 | - Moved `TgScraper\StubCreator` to the new namespace `TgScraper\Common\StubCreator`. 143 | - Moved scraping logic from `TgScraper\Generator` to the new class `TgScraper\Common\SchemaExtractor`. 144 | - CLI has been completely reworked: it now uses the Symfony Console and it's much more reliable! 145 | 146 | ## [1.4.0] - 2021-06-23 147 | ### Added 148 | - YAML format is now supported. 149 | - Docker support! The package is published on the GitHub registry. 150 | 151 | ## [1.3.0] - 2021-06-22 152 | ### Added 153 | - Badges in the README! They contain a lot of useful information about the project (such as the minimum PHP version, latest stable version, etc). 154 | 155 | ### Fixed 156 | - CLI now catches exceptions more reliably. 157 | 158 | ### Security 159 | - Dependency `paquettg/php-html-parser` has been upgraded to `^3.1`. 160 | 161 | ## [1.2.2] - 2021-06-20 162 | ### Fixed 163 | - Fixed a typo in a property name of the `Response` class stub. 164 | 165 | ## [1.2.1] - 2021-06-19 166 | ### Removed 167 | - The abstract constructor for the `API` trait stub has now been removed. 168 | 169 | ## [1.2.0] - 2021-06-19 170 | ### Changed 171 | - The `API` class stub has been converted to a trait, and the constructor and the `sendRequest` methods are now abstract. 172 | 173 | ### Fixed 174 | - Minor improvements to the CLI. 175 | 176 | ## [1.1.0] - 2021-06-18 177 | ### Added 178 | - New field for types: `optional`. It tells whether a value is always present or not. 179 | - Class stubs now have typed properties (with related PHPDoc comments). 180 | 181 | ### Changed 182 | - Variable names have been changed to `camelCase`. 183 | 184 | ## [1.0.2] - 2021-06-18 185 | ### Fixed 186 | - Improved argument parsing for the CLI. 187 | 188 | ## [1.0.1] - 2021-06-17 189 | ### Changed 190 | - Project license is now the GNU Lesser GPL. 191 | 192 | ## [1.0.0] - 2021-06-17 193 | ### Added 194 | - New CLI to easily generate JSON schema or class stubs! 195 | - It's now possible to parse old bot API webpages! Pass the URL and it should work just fine. 196 | - New API class stub, it implements all bot API methods (it's incomplete though, so you must add your custom logic). 197 | 198 | ### Changed 199 | - Renamed project to `tgscraper`. 200 | - Class namespace is now `TgScraper`, for the sake of consistency. 201 | - `StubProvider` class is now named `StubCreator`. 202 | - Reworked syntax for array of objects in field types: `Object[]` has been replaced by `Array`. 203 | 204 | ### Removed 205 | - Method stubs will no longer be generated. 206 | 207 | ### Fixed 208 | - The parser is now more reliable, it no longer needs to be updated at every bot API release! 209 | 210 | [Unreleased]: https://github.com/Sysbot-org/tgscraper/compare/4.0.9...HEAD 211 | [4.0.9]: https://github.com/Sysbot-org/tgscraper/compare/4.0.8...4.0.9 212 | [4.0.8]: https://github.com/Sysbot-org/tgscraper/compare/4.0.7...4.0.8 213 | [4.0.7]: https://github.com/Sysbot-org/tgscraper/compare/4.0.6...4.0.7 214 | [4.0.6]: https://github.com/Sysbot-org/tgscraper/compare/4.0.5...4.0.6 215 | [4.0.5]: https://github.com/Sysbot-org/tgscraper/compare/4.0.4...4.0.5 216 | [4.0.4]: https://github.com/Sysbot-org/tgscraper/compare/4.0.3...4.0.4 217 | [4.0.3]: https://github.com/Sysbot-org/tgscraper/compare/4.0.2...4.0.3 218 | [4.0.2]: https://github.com/Sysbot-org/tgscraper/compare/4.0.1...4.0.2 219 | [4.0.1]: https://github.com/Sysbot-org/tgscraper/compare/4.0...4.0.1 220 | [4.0.0]: https://github.com/Sysbot-org/tgscraper/compare/3.0.3...4.0 221 | [3.0.3]: https://github.com/Sysbot-org/tgscraper/compare/3.0.2...3.0.3 222 | [3.0.2]: https://github.com/Sysbot-org/tgscraper/compare/3.0.1...3.0.2 223 | [3.0.1]: https://github.com/Sysbot-org/tgscraper/compare/3.0...3.0.1 224 | [3.0.0]: https://github.com/Sysbot-org/tgscraper/compare/2.1...3.0 225 | [2.1.0]: https://github.com/Sysbot-org/tgscraper/compare/2.0.1...2.1 226 | [2.0.1]: https://github.com/Sysbot-org/tgscraper/compare/2.0...2.0.1 227 | [2.0.0]: https://github.com/Sysbot-org/tgscraper/compare/1.4...2.0 228 | [1.4.0]: https://github.com/Sysbot-org/tgscraper/compare/1.3...1.4 229 | [1.3.0]: https://github.com/Sysbot-org/tgscraper/compare/1.2.2...1.3 230 | [1.2.2]: https://github.com/Sysbot-org/tgscraper/compare/1.2.1...1.2.2 231 | [1.2.1]: https://github.com/Sysbot-org/tgscraper/compare/1.2...1.2.1 232 | [1.2.0]: https://github.com/Sysbot-org/tgscraper/compare/1.1...1.2 233 | [1.1.0]: https://github.com/Sysbot-org/tgscraper/compare/1.0.2...1.1 234 | [1.0.2]: https://github.com/Sysbot-org/tgscraper/compare/1.0.1...1.0.2 235 | [1.0.1]: https://github.com/Sysbot-org/tgscraper/compare/1.0...1.0.1 236 | [1.0.0]: https://github.com/Sysbot-org/tgscraper/releases/tag/1.0 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM composer:latest AS tgscraper 2 | 3 | MAINTAINER Sys 4 | 5 | WORKDIR /app 6 | RUN composer require sysbot/tgscraper sysbot/tgscraper-cache --no-progress --no-interaction --no-ansi --prefer-stable --optimize-autoloader 7 | WORKDIR /out 8 | VOLUME /out 9 | 10 | ENTRYPOINT ["php", "/app/vendor/bin/tgscraper"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 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 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TGScraper 2 | 3 | [![License](http://poser.pugx.org/sysbot/tgscraper/license)](https://packagist.org/packages/sysbot/tgscraper) 4 | ![Required PHP Version](https://img.shields.io/badge/php-%E2%89%A58.0-brightgreen) 5 | [![Latest Stable Version](http://poser.pugx.org/sysbot/tgscraper/v)](https://packagist.org/packages/sysbot/tgscraper) 6 | [![Dependencies](https://img.shields.io/librariesio/github/Sysbot-org/tgscraper)](https://libraries.io/github/Sysbot-org/tgscraper) 7 | [![Code Quality](https://img.shields.io/scrutinizer/quality/g/Sysbot-org/tgscraper)](https://scrutinizer-ci.com/g/Sysbot-org/tgscraper/?branch=master) 8 | 9 | A PHP library used to extract JSON data (and auto-generate PHP classes) 10 | from [Telegram bot API documentation page](https://core.telegram.org/bots/api). 11 | 12 | ## Changelog 13 | 14 | Interested in recent changes? Have a look [here](CHANGELOG.md)! 15 | 16 | 17 | ## Installation 18 | 19 | Install the library with composer: 20 | 21 | ```bash 22 | $ composer require sysbot/tgscraper --prefer-stable 23 | ``` 24 | 25 | (Optional) Install the cache package: 26 | 27 | ```bash 28 | $ composer require sysbot/tgscraper-cache 29 | ``` 30 | 31 | ## Using from command line 32 | 33 | Once installed, you can use the CLI to interact with the library. 34 | 35 | For basic help and command list: 36 | 37 | ```bash 38 | $ vendor/bin/tgscraper help 39 | ``` 40 | 41 | ### JSON 42 | 43 | Extract the latest schema in a human-readable JSON: 44 | 45 | ```bash 46 | $ vendor/bin/tgscraper app:export-schema --readable botapi.json 47 | ``` 48 | 49 | Or, if you want a Postman-compatible JSON (thanks to [davtur19](https://github.com/davtur19/TuriBotGen/blob/master/postman.php)): 50 | 51 | ```bash 52 | $ vendor/bin/tgscraper app:export-schema --postman botapi_postman.json 53 | ``` 54 | 55 | ### YAML 56 | 57 | Extract the latest schema in YAML format: 58 | 59 | ```bash 60 | $ vendor/bin/tgscraper app:export-schema --yaml botapi.yaml 61 | ``` 62 | 63 | ### OpenAPI 64 | 65 | Extract the latest OpenAPI schema in JSON format: 66 | 67 | ```bash 68 | $ vendor/bin/tgscraper app:export-schema --openapi botapi_openapi.json 69 | ``` 70 | 71 | Or, if you prefer YAML: 72 | 73 | ```bash 74 | $ vendor/bin/tgscraper app:export-schema --openapi --yaml botapi_openapi.yaml 75 | ``` 76 | 77 | ### Stubs 78 | 79 | _Note: since Telegram may change the page format at any time, do **NOT** rely on the automagically generated 80 | stubs from this library, **ALWAYS** review the code!_ 81 | 82 | TGScraper can also generate class stubs that you can use in your library. A sample implementation is available in the [Sysbot Telegram module](https://github.com/Sysbot-org/Sysbot-tg). 83 | 84 | Create stubs in the `out/` directory using `Sysbot\Telegram` as namespace prefix: 85 | 86 | ```bash 87 | $ vendor/bin/tgscraper app:create-stubs --namespace-prefix "Sysbot\Telegram" out 88 | ``` 89 | 90 | ### All versions 91 | 92 | If you want to generate all schemas and stubs for every Bot API version, you can! 93 | 94 | Here's an example on how to export everything to the `out/` directory, with schemas in human-readable format and using `Sysbot\Telegram` as namespace prefix for the stubs: 95 | 96 | ```bash 97 | $ vendor/bin/tgscraper app:dump-schemas -r --namespace-prefix "Sysbot\Telegram" out 98 | ``` 99 | 100 | ## Custom format 101 | 102 | If you're interested in the custom format generated by TGScraper, you can find its schema [here](docs/schema.json). -------------------------------------------------------------------------------- /bin/tgscraper: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new CreateStubsCommand()); 26 | $application->add(new ExportSchemaCommand()); 27 | $application->add(new DumpSchemasCommand()); 28 | 29 | try { 30 | $exitCode = $application->run(); 31 | } catch (Throwable $e) { 32 | echo $e->getMessage() . PHP_EOL; 33 | } 34 | 35 | exit($exitCode ?? 1); 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sysbot/tgscraper", 3 | "description": "Utility to extract scheme from Telegram Bot API webpage.", 4 | "license": "LGPL-3.0-or-later", 5 | "require": { 6 | "php": ">=8.0", 7 | "ext-json": "*", 8 | "composer-runtime-api": "^2.0", 9 | "guzzlehttp/guzzle": "^7.0", 10 | "nette/php-generator": "^4.0", 11 | "psr/log": "^1.1", 12 | "symfony/console": "^6.0", 13 | "symfony/yaml": "^6.0", 14 | "voku/simple_html_dom": "^4.7" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "^9.5", 18 | "phpstan/phpstan": "^1.2", 19 | "vimeo/psalm": "^4.15" 20 | }, 21 | "suggest": { 22 | "sysbot/tgscraper-cache": "To speed up schema fetching and generation." 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "TgScraper\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "TgScraper\\Tests\\": "tests/" 32 | } 33 | }, 34 | "bin": [ 35 | "bin/tgscraper" 36 | ], 37 | "authors": [ 38 | { 39 | "name": "sys-001", 40 | "email": "sys@sys001.ml", 41 | "homepage": "https://sys001.ml", 42 | "role": "Developer" 43 | } 44 | ], 45 | "support": { 46 | "issues": "https://github.com/Sysbot-org/tgscraper/issues" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Schema for TGScraper custom Bot API format", 4 | "description": "This schema should help you understanding the custom format used by TGScraper.", 5 | "type": "object", 6 | "required": [ 7 | "version", 8 | "methods", 9 | "types" 10 | ], 11 | "properties": { 12 | "version": { 13 | "type": "string" 14 | }, 15 | "methods": { 16 | "type": "array", 17 | "items": { 18 | "$ref": "#/definitions/Method" 19 | } 20 | }, 21 | "types": { 22 | "type": "array", 23 | "items": { 24 | "$ref": "#/definitions/Type" 25 | } 26 | } 27 | }, 28 | "definitions": { 29 | "Method": { 30 | "type": "object", 31 | "required": [ 32 | "name", 33 | "description", 34 | "fields", 35 | "return_types" 36 | ], 37 | "properties": { 38 | "name": { 39 | "type": "string" 40 | }, 41 | "description": { 42 | "type": "string" 43 | }, 44 | "fields": { 45 | "type": "array", 46 | "items": { 47 | "$ref": "#/definitions/Field" 48 | } 49 | }, 50 | "return_types": { 51 | "type": "array", 52 | "items": { 53 | "type": "string" 54 | } 55 | } 56 | } 57 | }, 58 | "Field": { 59 | "type": "object", 60 | "required": [ 61 | "name", 62 | "types", 63 | "optional", 64 | "description", 65 | "default" 66 | ], 67 | "properties": { 68 | "name": { 69 | "type": "string" 70 | }, 71 | "types": { 72 | "type": "array", 73 | "items": { 74 | "type": "string" 75 | } 76 | }, 77 | "optional": { 78 | "type": "boolean" 79 | }, 80 | "description": { 81 | "type": "string" 82 | }, 83 | "default": { 84 | "oneOf": [ 85 | { 86 | "type": "boolean" 87 | }, 88 | { 89 | "type": "integer" 90 | }, 91 | { 92 | "type": "object" 93 | }, 94 | { 95 | "type": "string" 96 | } 97 | ] 98 | } 99 | } 100 | }, 101 | "Type": { 102 | "type": "object", 103 | "required": [ 104 | "name", 105 | "description", 106 | "fields", 107 | "extended_by" 108 | ], 109 | "properties": { 110 | "name": { 111 | "type": "string" 112 | }, 113 | "description": { 114 | "type": "string" 115 | }, 116 | "fields": { 117 | "type": "array", 118 | "items": { 119 | "$ref": "#/definitions/Field" 120 | } 121 | }, 122 | "extended_by": { 123 | "type": "array", 124 | "items": { 125 | "type": "string" 126 | } 127 | } 128 | } 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Commands/Common.php: -------------------------------------------------------------------------------- 1 | critical($prefix . 'Unable to save file to ' . $destination); 23 | return Command::FAILURE; 24 | } 25 | if ($log) { 26 | $logger->info($prefix . 'Done!'); 27 | return Command::SUCCESS; 28 | } 29 | $output->writeln($prefix . 'Done!'); 30 | return Command::SUCCESS; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/Commands/CreateStubsCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Create stubs from bot API schema.') 32 | ->setHelp('This command allows you to create class stubs for all types of the Telegram bot API.') 33 | ->addArgument('destination', InputArgument::REQUIRED, 'Destination directory') 34 | ->addOption('namespace-prefix', null, InputOption::VALUE_REQUIRED, 'Namespace prefix for stubs', 'TelegramApi') 35 | ->addOption( 36 | 'json', 37 | null, 38 | InputOption::VALUE_REQUIRED, 39 | 'Path to JSON file to use instead of fetching from URL (this option takes precedence over "--layer")' 40 | ) 41 | ->addOption( 42 | 'yaml', 43 | null, 44 | InputOption::VALUE_REQUIRED, 45 | 'Path to YAML file to use instead of fetching from URL (this option takes precedence over "--layer" and "--json")' 46 | ) 47 | ->addOption('layer', 'l', InputOption::VALUE_REQUIRED, 'Bot API version to use', 'latest') 48 | ->addOption( 49 | 'prefer-stable', 50 | null, 51 | InputOption::VALUE_NONE, 52 | 'Prefer latest stable version (takes precedence over "--layer")' 53 | ); 54 | } 55 | 56 | protected function execute(InputInterface $input, OutputInterface $output): int 57 | { 58 | $logger = new ConsoleLogger($output); 59 | $version = Versions::getVersionFromText($input->getOption('layer')); 60 | if ($input->getOption('prefer-stable')) { 61 | $version = Versions::STABLE; 62 | } 63 | $yamlPath = $input->getOption('yaml'); 64 | if (empty($yamlPath)) { 65 | $jsonPath = $input->getOption('json'); 66 | if (empty($jsonPath)) { 67 | $logger->info('Using version: ' . $version); 68 | try { 69 | $output->writeln('Fetching data for version...'); 70 | $generator = TgScraper::fromVersion($logger, $version); 71 | } catch (Throwable) { 72 | return Command::FAILURE; 73 | } 74 | } else { 75 | $data = file_get_contents($jsonPath); 76 | if (!$this->validateData($data)) { 77 | $logger->critical('Invalid JSON file provided'); 78 | return Command::INVALID; 79 | } 80 | $logger->info('Using JSON schema: ' . $jsonPath); 81 | /** @noinspection PhpUnhandledExceptionInspection */ 82 | $generator = TgScraper::fromJson($logger, $data); 83 | } 84 | } else { 85 | $data = file_get_contents($yamlPath); 86 | if (!$this->validateData($data)) { 87 | $logger->critical('Invalid YAML file provided'); 88 | return Command::INVALID; 89 | } 90 | $logger->info('Using YAML schema: ' . $yamlPath); 91 | /** @noinspection PhpUnhandledExceptionInspection */ 92 | $generator = TgScraper::fromYaml($logger, $data); 93 | } 94 | try { 95 | $output->writeln('Creating stubs...'); 96 | $generator->toStubs($input->getArgument('destination'), $input->getOption('namespace-prefix')); 97 | } catch (Exception) { 98 | $logger->critical('Could not create stubs.'); 99 | return Command::FAILURE; 100 | } 101 | $output->writeln('Done!'); 102 | return Command::SUCCESS; 103 | } 104 | 105 | } -------------------------------------------------------------------------------- /src/Commands/DumpSchemasCommand.php: -------------------------------------------------------------------------------- 1 | isDir() ? 'rmdir' : 'unlink'); 37 | $todo($fileInfo->getRealPath()); 38 | } 39 | rmdir($directory); 40 | } 41 | 42 | protected function configure(): void 43 | { 44 | $this 45 | ->setDescription('Export all schemas and stubs to a directory.') 46 | ->setHelp('This command allows you to generate the schemas for all versions of the Telegram bot API.') 47 | ->addArgument('destination', InputArgument::REQUIRED, 'Destination directory') 48 | ->addOption( 49 | 'namespace-prefix', 50 | null, 51 | InputOption::VALUE_REQUIRED, 52 | 'Namespace prefix for stubs', 53 | 'TelegramApi' 54 | ) 55 | ->addOption( 56 | 'readable', 57 | 'r', 58 | InputOption::VALUE_NONE, 59 | 'Generate human-readable files' 60 | ); 61 | } 62 | 63 | protected function execute(InputInterface $input, OutputInterface $output): int 64 | { 65 | $versionReplacer = function (string $ver) { 66 | /** @noinspection PhpUndefinedFieldInspection */ 67 | $this->version = $ver; 68 | }; 69 | $logger = new ConsoleLogger($output); 70 | $destination = $input->getArgument('destination'); 71 | $readable = $input->getOption('readable'); 72 | $output->writeln('Creating directory tree...'); 73 | try { 74 | $destination = TgScraper::getTargetDirectory($destination); 75 | mkdir($destination . '/custom/json', 0755, true); 76 | mkdir($destination . '/custom/yaml', 0755, true); 77 | mkdir($destination . '/postman', 0755, true); 78 | mkdir($destination . '/openapi/json', 0755, true); 79 | mkdir($destination . '/openapi/yaml', 0755, true); 80 | mkdir($destination . '/stubs', 0755, true); 81 | } catch (Exception $e) { 82 | $logger->critical((string)$e); 83 | return Command::FAILURE; 84 | } 85 | $versions = array_keys( 86 | (new ReflectionClass(Versions::class)) 87 | ->getConstants()['URLS'] 88 | ); 89 | $versions = array_diff($versions, ['latest']); 90 | foreach ($versions as $version) { 91 | $output->writeln(sprintf('Generating v%s schemas...', $version)); 92 | $filename = 'v' . str_replace('.', '', $version); 93 | try { 94 | $logger->info($version . ': Fetching data...'); 95 | $generator = TgScraper::fromVersion($logger, $version); 96 | } catch (Throwable $e) { 97 | $logger->critical((string)$e); 98 | return Command::FAILURE; 99 | } 100 | $versionReplacer->call($generator, $version); 101 | $custom = $generator->toArray(); 102 | $postman = $generator->toPostman(); 103 | $openapi = $generator->toOpenApi(); 104 | try { 105 | $logger->info($version . ': Creating stubs...'); 106 | $generator->toStubs("$destination/tmp", $input->getOption('namespace-prefix')); 107 | } catch (Exception) { 108 | $logger->critical($version . ': Could not create stubs.'); 109 | return Command::FAILURE; 110 | } 111 | $logger->info($version . ': Compressing stubs...'); 112 | $zip = new PharData("$destination/stubs/$filename.zip"); 113 | $zip->buildFromDirectory("$destination/tmp"); 114 | self::rrmdir("$destination/tmp"); 115 | $logger->info($version . ': Saving schemas...'); 116 | if ($this->saveFile( 117 | $logger, 118 | $output, 119 | "$destination/custom/json/$filename.json", 120 | Encoder::toJson($custom, readable: $readable), 121 | sprintf('v%s custom (JSON): ', $version) 122 | ) !== Command::SUCCESS) { 123 | return Command::FAILURE; 124 | } 125 | if ($this->saveFile( 126 | $logger, 127 | $output, 128 | "$destination/custom/yaml/$filename.yaml", 129 | Encoder::toYaml($custom), 130 | sprintf('v%s custom (YAML): ', $version) 131 | ) !== Command::SUCCESS) { 132 | return Command::FAILURE; 133 | } 134 | if ($this->saveFile( 135 | $logger, 136 | $output, 137 | "$destination/postman/$filename.json", 138 | Encoder::toJson($postman, readable: $readable), 139 | sprintf('v%s Postman: ', $version) 140 | ) !== Command::SUCCESS) { 141 | return Command::FAILURE; 142 | } 143 | if ($this->saveFile( 144 | $logger, 145 | $output, 146 | "$destination/openapi/json/$filename.json", 147 | Encoder::toJson($openapi, readable: $readable), 148 | sprintf('v%s OpenAPI (JSON): ', $version) 149 | ) !== Command::SUCCESS) { 150 | return Command::FAILURE; 151 | } 152 | if ($this->saveFile( 153 | $logger, 154 | $output, 155 | "$destination/openapi/yaml/$filename.yaml", 156 | Encoder::toYaml($openapi), 157 | sprintf('v%s OpenAPI (YAML): ', $version) 158 | ) !== Command::SUCCESS) { 159 | return Command::FAILURE; 160 | } 161 | $logger->info($version . ': Done!'); 162 | } 163 | $output->writeln('Done!'); 164 | return Command::SUCCESS; 165 | } 166 | 167 | } -------------------------------------------------------------------------------- /src/Commands/ExportSchemaCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Export schema as JSON or YAML.') 30 | ->setHelp('This command allows you to create a schema for a specific version of the Telegram bot API.') 31 | ->addArgument('destination', InputArgument::REQUIRED, 'Destination file') 32 | ->addOption( 33 | 'yaml', 34 | null, 35 | InputOption::VALUE_NONE, 36 | 'Export schema as YAML instead of JSON (does not affect "--postman")' 37 | ) 38 | ->addOption( 39 | 'postman', 40 | null, 41 | InputOption::VALUE_NONE, 42 | 'Export schema as a Postman-compatible JSON' 43 | ) 44 | ->addOption( 45 | 'openapi', 46 | null, 47 | InputOption::VALUE_NONE, 48 | 'Export schema as a OpenAPI-compatible file (takes precedence over "--postman")' 49 | ) 50 | ->addOption('options', 'o', InputOption::VALUE_REQUIRED, 'Encoder options', 0) 51 | ->addOption( 52 | 'readable', 53 | 'r', 54 | InputOption::VALUE_NONE, 55 | 'Generate a human-readable file (overrides "--inline" and "--indent")' 56 | ) 57 | ->addOption('inline', null, InputOption::VALUE_REQUIRED, '(YAML only) Inline level', 16) 58 | ->addOption('indent', null, InputOption::VALUE_REQUIRED, '(YAML only) Indent level', 4) 59 | ->addOption('layer', 'l', InputOption::VALUE_REQUIRED, 'Bot API version to use', Versions::LATEST) 60 | ->addOption( 61 | 'prefer-stable', 62 | null, 63 | InputOption::VALUE_NONE, 64 | 'Prefer latest stable version (takes precedence over "--layer")' 65 | ); 66 | } 67 | 68 | protected function execute(InputInterface $input, OutputInterface $output): int 69 | { 70 | $logger = new ConsoleLogger($output); 71 | $version = Versions::getVersionFromText($input->getOption('layer')); 72 | if ($input->getOption('prefer-stable')) { 73 | $version = Versions::STABLE; 74 | } 75 | $logger->info('Using version: ' . $version); 76 | try { 77 | $output->writeln('Fetching data for version...'); 78 | $generator = TgScraper::fromVersion($logger, $version); 79 | } catch (Throwable $e) { 80 | $logger->critical((string)$e); 81 | return Command::FAILURE; 82 | } 83 | $output->writeln('Exporting schema from data...'); 84 | $destination = $input->getArgument('destination'); 85 | try { 86 | TgScraper::getTargetDirectory(pathinfo($destination)['dirname']); 87 | } catch (Exception) { 88 | return Command::FAILURE; 89 | } 90 | $readable = $input->getOption('readable'); 91 | $options = $input->getOption('options'); 92 | $useYaml = $input->getOption('yaml'); 93 | $inline = $readable ? 16 : $input->getOption('inline'); 94 | $indent = $readable ? 4 : $input->getOption('indent'); 95 | $output->writeln('Saving schema to file...'); 96 | if ($input->getOption('openapi')) { 97 | $data = $generator->toOpenApi(); 98 | if ($useYaml) { 99 | return $this->saveFile($logger, $output, $destination, Encoder::toYaml($data, $inline, $indent, $options), log: false); 100 | } 101 | return $this->saveFile($logger, $output, $destination, Encoder::toJson($data, $options | JSON_UNESCAPED_SLASHES, $readable), log: false); 102 | } 103 | if ($input->getOption('postman')) { 104 | $data = $generator->toPostman(); 105 | return $this->saveFile($logger, $output, $destination, Encoder::toJson($data, $options, $readable), log: false); 106 | } 107 | $data = $generator->toArray(); 108 | if ($useYaml) { 109 | return $this->saveFile($logger, $output, $destination, Encoder::toYaml($data, $inline, $indent, $options), log: false); 110 | } 111 | return $this->saveFile($logger, $output, $destination, Encoder::toJson($data, $options, $readable), log: false); 112 | } 113 | 114 | } -------------------------------------------------------------------------------- /src/Common/Encoder.php: -------------------------------------------------------------------------------- 1 | addTypes($types); 12 | $this->addMethods($methods); 13 | } 14 | 15 | public function setVersion($version = '1.0.0'): self 16 | { 17 | $this->data['info']['version'] = $version; 18 | return $this; 19 | } 20 | 21 | public function addMethods(array $methods): self 22 | { 23 | foreach ($methods as $method) { 24 | $this->addMethod($method['name'], $method['description'], $method['fields'], $method['return_types']); 25 | } 26 | return $this; 27 | } 28 | 29 | public function addMethod(string $name, string $description, array $fields, array $returnTypes): self 30 | { 31 | $method = '/' . $name; 32 | $this->data['paths'][$method] = ['description' => $description]; 33 | $path = []; 34 | $fields = self::addFields(['type' => 'object'], $fields); 35 | $content = ['schema' => $fields]; 36 | if (!empty($fields['required'])) { 37 | $path['requestBody']['required'] = true; 38 | } 39 | $path['requestBody']['content'] = [ 40 | 'application/json' => $content, 41 | 'application/x-www-form-urlencoded' => $content, 42 | 'multipart/form-data' => $content 43 | ]; 44 | $path['responses'] = $this->defaultResponses; 45 | $path['responses']['200']['content']['application/json']['schema'] 46 | ['allOf'][1]['properties']['result'] = self::parsePropertyTypes($returnTypes); 47 | $this->data['paths'][$method]['post'] = $path; 48 | return $this; 49 | } 50 | 51 | public function addTypes(array $types): self 52 | { 53 | foreach ($types as $type) { 54 | $this->addType($type['name'], $type['description'], $type['fields'], $type['extended_by']); 55 | } 56 | return $this; 57 | } 58 | 59 | public function addType(string $name, string $description, array $fields, array $extendedBy): self 60 | { 61 | $schema = ['description' => $description]; 62 | $schema = self::addFields($schema, $fields); 63 | $this->data['components']['schemas'][$name] = $schema; 64 | if (!empty($extendedBy)) { 65 | foreach ($extendedBy as $extendedType) { 66 | $this->data['components']['schemas'][$name]['anyOf'][] = self::parsePropertyType($extendedType); 67 | } 68 | return $this; 69 | } 70 | $this->data['components']['schemas'][$name]['type'] = 'object'; 71 | return $this; 72 | } 73 | 74 | private static function addFields(array $schema, array $fields): array 75 | { 76 | foreach ($fields as $field) { 77 | $name = $field['name']; 78 | $required = !$field['optional']; 79 | if ($required) { 80 | $schema['required'][] = $name; 81 | } 82 | $schema['properties'][$name] = self::parsePropertyTypes($field['types']); 83 | if (!empty($field['default'] ?? null)) { 84 | $schema['properties'][$name]['default'] = $field['default']; 85 | } 86 | } 87 | return $schema; 88 | } 89 | 90 | private static function parsePropertyTypes(array $types): array 91 | { 92 | $result = []; 93 | $hasMultipleTypes = count($types) > 1; 94 | foreach ($types as $type) { 95 | $type = self::parsePropertyType(trim($type)); 96 | if ($hasMultipleTypes) { 97 | $result['anyOf'][] = $type; 98 | continue; 99 | } 100 | $result = $type; 101 | } 102 | return $result; 103 | } 104 | 105 | private static function parsePropertyType(string $type): array 106 | { 107 | if (str_starts_with($type, 'Array')) { 108 | return self::parsePropertyArray($type); 109 | } 110 | if (lcfirst($type) == $type) { 111 | $type = str_replace(['int', 'float', 'bool'], ['integer', 'number', 'boolean'], $type); 112 | return ['type' => $type]; 113 | } 114 | return ['$ref' => '#/components/schemas/' . $type]; 115 | } 116 | 117 | private static function parsePropertyArray(string $type): array 118 | { 119 | if (preg_match('/Array<(.+)>/', $type, $matches) === 1) { 120 | return [ 121 | 'type' => 'array', 122 | 'items' => self::parsePropertyTypes(explode('|', $matches[1])) 123 | ]; 124 | } 125 | return []; 126 | } 127 | 128 | /** 129 | * @return array 130 | */ 131 | public function getData(): array 132 | { 133 | return $this->data; 134 | } 135 | } -------------------------------------------------------------------------------- /src/Common/SchemaExtractor.php: -------------------------------------------------------------------------------- 1 | version = $this->parseVersion(); 40 | $this->logger->info('Bot API version: ' . $this->version); 41 | } 42 | 43 | 44 | /** 45 | * @param LoggerInterface $logger 46 | * @param string $version 47 | * @return SchemaExtractor 48 | * @throws OutOfBoundsException 49 | * @throws Exception 50 | * @throws GuzzleException 51 | */ 52 | public static function fromVersion(LoggerInterface $logger, string $version = Versions::LATEST): SchemaExtractor 53 | { 54 | if (InstalledVersions::isInstalled('sysbot/tgscraper-cache') and class_exists('\TgScraper\Cache\CacheLoader')) { 55 | $logger->info('Cache package detected, searching for a cached version.'); 56 | try { 57 | /** @noinspection PhpFullyQualifiedNameUsageInspection */ 58 | /** @noinspection PhpUndefinedNamespaceInspection */ 59 | /** @psalm-suppress UndefinedClass */ 60 | $path = \TgScraper\Cache\CacheLoader::getCachedVersion($version); 61 | $logger->info('Cached version found.'); 62 | return self::fromFile($logger, $path); 63 | } catch (OutOfBoundsException) { 64 | $logger->info('Cached version not found, continuing with URL.'); 65 | } 66 | } 67 | $url = Versions::getUrlFromText($version); 68 | $logger->info(sprintf('Using URL: %s', $url)); 69 | return self::fromUrl($logger, $url); 70 | } 71 | 72 | /** 73 | * @param LoggerInterface $logger 74 | * @param string $path 75 | * @return SchemaExtractor 76 | * @throws InvalidArgumentException 77 | * @throws RuntimeException 78 | */ 79 | public static function fromFile(LoggerInterface $logger, string $path): SchemaExtractor 80 | { 81 | if (!file_exists($path) or is_dir($path)) { 82 | throw new InvalidArgumentException('File not found'); 83 | } 84 | $path = realpath($path); 85 | try { 86 | $logger->info(sprintf('Loading data from file "%s".', $path)); 87 | $dom = HtmlDomParser::file_get_html($path); 88 | $logger->info('Data loaded.'); 89 | } catch (RuntimeException $e) { 90 | $logger->critical(sprintf('Unable to load data from "%s": %s', $path, $e->getMessage())); 91 | throw $e; 92 | } 93 | return new self($logger, $dom); 94 | } 95 | 96 | /** 97 | * @param LoggerInterface $logger 98 | * @param string $url 99 | * @return SchemaExtractor 100 | * @throws GuzzleException 101 | */ 102 | public static function fromUrl(LoggerInterface $logger, string $url): SchemaExtractor 103 | { 104 | $client = new Client(); 105 | try { 106 | $html = $client->get($url)->getBody(); 107 | $dom = HtmlDomParser::str_get_html((string)$html); 108 | } catch (GuzzleException $e) { 109 | $logger->critical(sprintf('Unable to load data from URL "%s": %s', $url, $e->getMessage())); 110 | throw $e; 111 | } 112 | $logger->info(sprintf('Data loaded from "%s".', $url)); 113 | return new self($logger, $dom); 114 | } 115 | 116 | /** 117 | * @param SimpleHtmlDomInterface $node 118 | * @return array{description: string, table: ?SimpleHtmlDomNodeInterface, extended_by: string[]} 119 | */ 120 | private static function parseNode(SimpleHtmlDomInterface $node): array 121 | { 122 | $description = ''; 123 | $table = null; 124 | $extendedBy = []; 125 | $tag = ''; 126 | $sibling = $node; 127 | while (!str_starts_with($tag ?? '', 'h')) { 128 | $sibling = $sibling?->nextSibling(); 129 | $tag = $sibling?->tag; 130 | if (empty($node->text()) or empty($tag) or $tag == 'text' or empty($sibling)) { 131 | continue; 132 | } 133 | switch ($tag) { 134 | case 'p': 135 | $description .= PHP_EOL . $sibling->innerHtml(); 136 | break; 137 | case 'ul': 138 | $items = $sibling->findMulti('li'); 139 | foreach ($items as $item) { 140 | $extendedBy[] = $item->text(); 141 | } 142 | break 2; 143 | case 'table': 144 | /** @var SimpleHtmlDomNodeInterface $table */ 145 | $table = $sibling->findOne('tbody')->findMulti('tr'); 146 | break 2; 147 | } 148 | } 149 | return ['description' => $description, 'table' => $table, 'extended_by' => $extendedBy]; 150 | } 151 | 152 | /** 153 | * @return string 154 | */ 155 | private function parseVersion(): string 156 | { 157 | $element = $this->dom->findOne('h3'); 158 | $tag = ''; 159 | while ($tag != 'p' and !empty($element)) { 160 | $element = $element->nextSibling(); 161 | $tag = $element?->tag; 162 | } 163 | if (empty($element)) { 164 | return '1.0.0'; 165 | } 166 | $versionNumbers = explode('.', str_replace('Bot API ', '', $element->text())); 167 | return sprintf( 168 | '%s.%s.%s', 169 | $versionNumbers[0] ?? '1', 170 | $versionNumbers[1] ?? '0', 171 | $versionNumbers[2] ?? '0' 172 | ); 173 | } 174 | 175 | /** 176 | * @return string 177 | */ 178 | public function getVersion(): string 179 | { 180 | return $this->version; 181 | } 182 | 183 | /** 184 | * @return array{version: string, methods: array, types: array} 185 | * @throws Exception 186 | */ 187 | public function extract(): array 188 | { 189 | $elements = $this->dom->findMultiOrFalse('h4'); 190 | if (false === $elements) { 191 | throw new Exception('Unable to fetch required DOM nodes'); 192 | } 193 | $data = ['version' => $this->version, 'methods' => [], 'types' => []]; 194 | foreach ($elements as $element) { 195 | if (!str_contains($name = $element->text(), ' ')) { 196 | $isMethod = lcfirst($name) == $name; 197 | $path = $isMethod ? 'methods' : 'types'; 198 | ['description' => $description, 'table' => $table, 'extended_by' => $extendedBy] = self::parseNode( 199 | $element 200 | ); 201 | $data[$path][] = self::generateElement( 202 | $name, 203 | trim($description), 204 | $table, 205 | $extendedBy, 206 | $isMethod 207 | ); 208 | } 209 | } 210 | return $data; 211 | } 212 | 213 | /** 214 | * @param string $name 215 | * @param string $description 216 | * @param SimpleHtmlDomNodeInterface|null $unparsedFields 217 | * @param array $extendedBy 218 | * @param bool $isMethod 219 | * @return array 220 | */ 221 | private static function generateElement( 222 | string $name, 223 | string $description, 224 | ?SimpleHtmlDomNodeInterface $unparsedFields, 225 | array $extendedBy, 226 | bool $isMethod 227 | ): array { 228 | $fields = self::parseFields($unparsedFields, $isMethod); 229 | $result = [ 230 | 'name' => $name, 231 | 'description' => htmlspecialchars_decode(strip_tags($description), ENT_QUOTES), 232 | 'fields' => $fields 233 | ]; 234 | if ($isMethod) { 235 | $description = new ObjectDescription($description); 236 | $returnTypes = $description->getTypes(); 237 | $result['return_types'] = $returnTypes; 238 | return $result; 239 | } 240 | $result['extended_by'] = $extendedBy; 241 | return $result; 242 | } 243 | 244 | /** 245 | * @param SimpleHtmlDomNodeInterface|null $fields 246 | * @param bool $isMethod 247 | * @return array 248 | */ 249 | private static function parseFields(?SimpleHtmlDomNodeInterface $fields, bool $isMethod): array 250 | { 251 | $parsedFields = []; 252 | $fields ??= []; 253 | /** @var SimpleHtmlDomInterface $field */ 254 | foreach ($fields as $field) { 255 | /** @var SimpleHtmlDomNode $fieldData */ 256 | $fieldData = $field->findMulti('td'); 257 | $name = $fieldData[0]->text(); 258 | if (empty($name)) { 259 | continue; 260 | } 261 | $types = $fieldData[1]->text(); 262 | if ($isMethod) { 263 | $optional = $fieldData[2]->text() != 'Yes'; 264 | $description = $fieldData[3]->innerHtml(); 265 | } else { 266 | $description = $fieldData[2]->innerHtml(); 267 | $optional = str_starts_with($fieldData[2]->text(), 'Optional.'); 268 | } 269 | $field = new Field($name, $types, $optional, $description); 270 | $parsedFields[] = $field->toArray(); 271 | } 272 | return $parsedFields; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/Common/StubCreator.php: -------------------------------------------------------------------------------- 1 | schema)) { 54 | throw new InvalidArgumentException('Schema invalid'); 55 | } 56 | $this->getExtendedTypes(); 57 | $this->namespace = $namespace; 58 | } 59 | 60 | /** 61 | * Builds the abstract and the extended class lists. 62 | */ 63 | private function getExtendedTypes(): void 64 | { 65 | foreach ($this->schema['types'] as $type) { 66 | if (!empty($type['extended_by'])) { 67 | $this->abstractClasses[] = $type['name']; 68 | foreach ($type['extended_by'] as $extendedType) { 69 | $this->extendedClasses[$extendedType] = $type['name']; 70 | } 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * @param string $str 77 | * @return string 78 | */ 79 | private static function toCamelCase(string $str): string 80 | { 81 | return lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $str)))); 82 | } 83 | 84 | /** 85 | * @param array $fieldTypes 86 | * @param PhpNamespace $phpNamespace 87 | * @return array 88 | */ 89 | private function parseFieldTypes(array $fieldTypes, PhpNamespace $phpNamespace): array 90 | { 91 | $types = []; 92 | $comments = []; 93 | foreach ($fieldTypes as $fieldType) { 94 | $comments[] = $fieldType; 95 | if (str_starts_with($fieldType, 'Array')) { 96 | $types[] = 'array'; 97 | continue; 98 | } 99 | if (ucfirst($fieldType) == $fieldType) { 100 | $fieldType = $phpNamespace->getName() . '\\' . $fieldType; 101 | } 102 | $types[] = $fieldType; 103 | } 104 | $comments = empty($comments) ? '' : sprintf('@var %s', implode('|', $comments)); 105 | return [ 106 | 'types' => implode('|', $types), 107 | 'comments' => $comments 108 | ]; 109 | } 110 | 111 | /** 112 | * @param array $apiTypes 113 | * @param PhpNamespace $phpNamespace 114 | * @return array 115 | */ 116 | private function parseApiFieldTypes(array $apiTypes, PhpNamespace $phpNamespace): array 117 | { 118 | $types = []; 119 | $comments = []; 120 | foreach ($apiTypes as $apiType) { 121 | $comments[] = $apiType; 122 | if (str_starts_with($apiType, 'Array')) { 123 | $types[] = 'array'; 124 | $text = $apiType; 125 | while (preg_match('/Array<(.+)>/', $text, $matches) === 1) { 126 | $text = $matches[1]; 127 | } 128 | $subTypes = explode('|', $text); 129 | foreach ($subTypes as $subType) { 130 | if (ucfirst($subType) == $subType) { 131 | $subType = $this->namespace . '\\Types\\' . $subType; 132 | $phpNamespace->addUse($subType); 133 | } 134 | } 135 | continue; 136 | } 137 | if (ucfirst($apiType) == $apiType) { 138 | $apiType = $this->namespace . '\\Types\\' . $apiType; 139 | $phpNamespace->addUse($apiType); 140 | } 141 | $types[] = $apiType; 142 | } 143 | $comments = empty($comments) ? '' : sprintf('@param %s', implode('|', $comments)); 144 | return [ 145 | 'types' => implode('|', $types), 146 | 'comments' => $comments 147 | ]; 148 | } 149 | 150 | /** 151 | * @param string $namespace 152 | * @return PhpFile[] 153 | */ 154 | private function generateDefaultTypes(string $namespace): array 155 | { 156 | $interfaceFile = new PhpFile; 157 | $interfaceNamespace = $interfaceFile->addNamespace($namespace); 158 | $interfaceNamespace->addInterface('TypeInterface'); 159 | $responseFile = new PhpFile; 160 | $responseNamespace = $responseFile->addNamespace($namespace); 161 | $responseNamespace->addUse('stdClass'); 162 | $response = $responseNamespace->addClass('Response'); 163 | $response->addProperty('ok') 164 | ->setPublic() 165 | ->setType(Type::BOOL); 166 | $response->addProperty('result') 167 | ->setPublic() 168 | ->setType(sprintf('stdClass|%s\\TypeInterface|array|int|string|bool', $namespace)) 169 | ->setNullable() 170 | ->setValue(null); 171 | $response->addProperty('errorCode') 172 | ->setPublic() 173 | ->setType(Type::INT) 174 | ->setNullable() 175 | ->setValue(null); 176 | $response->addProperty('description') 177 | ->setPublic() 178 | ->setType(Type::STRING) 179 | ->setNullable() 180 | ->setValue(null); 181 | $response->addProperty('parameters') 182 | ->setPublic() 183 | ->setType(sprintf('stdClass|%s\\ResponseParameters', $namespace)) 184 | ->setNullable() 185 | ->setValue(null); 186 | $response->addImplement($namespace . '\\TypeInterface'); 187 | return [ 188 | 'Response' => $responseFile, 189 | 'TypeInterface' => $interfaceFile 190 | ]; 191 | } 192 | 193 | /** 194 | * @return PhpFile[] 195 | */ 196 | private function generateTypes(): array 197 | { 198 | $namespace = $this->namespace . '\\Types'; 199 | $types = $this->generateDefaultTypes($namespace); 200 | foreach ($this->schema['types'] as $type) { 201 | $file = new PhpFile; 202 | $phpNamespace = $file->addNamespace($namespace); 203 | $typeClass = $phpNamespace->addClass($type['name']); 204 | if (in_array($type['name'], $this->abstractClasses)) { 205 | $typeClass->setAbstract(); 206 | } 207 | if (array_key_exists($type['name'], $this->extendedClasses)) { 208 | $typeClass->setExtends($namespace . '\\' . $this->extendedClasses[$type['name']]); 209 | } else { 210 | $typeClass->addImplement($namespace . '\\TypeInterface'); 211 | } 212 | foreach ($type['fields'] as $field) { 213 | ['types' => $fieldTypes, 'comments' => $fieldComments] = $this->parseFieldTypes( 214 | $field['types'], 215 | $phpNamespace 216 | ); 217 | $fieldName = self::toCamelCase($field['name']); 218 | $typeProperty = $typeClass->addProperty($fieldName) 219 | ->setPublic() 220 | ->setType($fieldTypes); 221 | $default = $field['default'] ?? null; 222 | if (!empty($default)) { 223 | $typeProperty->setValue($default); 224 | } 225 | if ($field['optional']) { 226 | $typeProperty->setNullable(); 227 | if (!$typeProperty->isInitialized()) { 228 | $typeProperty->setValue(null); 229 | } 230 | $fieldComments .= '|null'; 231 | } 232 | if (!empty($fieldComments)) { 233 | $fieldComments .= ' ' . $field['description']; 234 | $typeProperty->addComment($fieldComments); 235 | } 236 | } 237 | $types[$type['name']] = $file; 238 | } 239 | return $types; 240 | } 241 | 242 | /** 243 | * @return PhpFile 244 | */ 245 | private function generateApi(): PhpFile 246 | { 247 | $file = new PhpFile; 248 | $file->addComment('@noinspection PhpUnused'); 249 | $file->addComment('@noinspection PhpUnusedParameterInspection'); 250 | $phpNamespace = $file->addNamespace($this->namespace); 251 | $apiClass = $phpNamespace->addTrait('API'); 252 | $sendRequest = $apiClass->addMethod('sendRequest') 253 | ->setPublic() 254 | ->setAbstract() 255 | ->setReturnType(Type::MIXED); 256 | $sendRequest->addParameter('method') 257 | ->setType(Type::STRING); 258 | $sendRequest->addParameter('args') 259 | ->setType(Type::ARRAY); 260 | foreach ($this->schema['methods'] as $method) { 261 | $function = $apiClass->addMethod($method['name']) 262 | ->setPublic() 263 | ->addBody('$args = get_defined_vars();') 264 | ->addBody('return $this->sendRequest(__FUNCTION__, $args);'); 265 | $function->addComment($method['description']); 266 | $fields = $method['fields']; 267 | usort( 268 | $fields, 269 | function ($a, $b) { 270 | return $a['optional'] - $b['optional']; 271 | } 272 | ); 273 | foreach ($fields as $field) { 274 | ['types' => $types, 'comments' => $comment] = $this->parseApiFieldTypes($field['types'], $phpNamespace); 275 | $fieldName = self::toCamelCase($field['name']); 276 | $parameter = $function->addParameter($fieldName) 277 | ->setType($types); 278 | $default = $field['default'] ?? null; 279 | if (!empty($default) and (!is_string($default) or lcfirst($default) == $default)) { 280 | $parameter->setDefaultValue($default); 281 | } 282 | if ($field['optional']) { 283 | $parameter->setNullable(); 284 | if (!$parameter->hasDefaultValue()) { 285 | $parameter->setDefaultValue(null); 286 | } 287 | $comment .= '|null'; 288 | } 289 | $comment .= sprintf(' $%s %s', $fieldName, $field['description']); 290 | $function->addComment($comment); 291 | } 292 | ['types' => $returnTypes, 'comments' => $returnComment] = $this->parseApiFieldTypes( 293 | $method['return_types'], 294 | $phpNamespace 295 | ); 296 | $function->setReturnType($returnTypes); 297 | $function->addComment(str_replace('param', 'return', $returnComment)); 298 | } 299 | return $file; 300 | } 301 | 302 | /** 303 | * @return array{types: PhpFile[], api: PhpFile} 304 | */ 305 | public function generateCode(): array 306 | { 307 | return [ 308 | 'types' => $this->generateTypes(), 309 | 'api' => $this->generateApi() 310 | ]; 311 | } 312 | 313 | } -------------------------------------------------------------------------------- /src/Constants/Versions.php: -------------------------------------------------------------------------------- 1 | 'https://web.archive.org/web/20150714025308id_/https://core.telegram.org/bots/api/', 67 | self::V110 => 'https://web.archive.org/web/20150812125616id_/https://core.telegram.org/bots/api', 68 | self::V140 => 'https://web.archive.org/web/20150909214252id_/https://core.telegram.org/bots/api', 69 | self::V150 => 'https://web.archive.org/web/20150921091215id_/https://core.telegram.org/bots/api/', 70 | self::V160 => 'https://web.archive.org/web/20151023071257id_/https://core.telegram.org/bots/api', 71 | self::V180 => 'https://web.archive.org/web/20160112101045id_/https://core.telegram.org/bots/api', 72 | self::V182 => 'https://web.archive.org/web/20160126005312id_/https://core.telegram.org/bots/api', 73 | self::V183 => 'https://web.archive.org/web/20160305132243id_/https://core.telegram.org/bots/api', 74 | self::V200 => 'https://web.archive.org/web/20160413101342id_/https://core.telegram.org/bots/api', 75 | self::V210 => 'https://web.archive.org/web/20160912130321id_/https://core.telegram.org/bots/api', 76 | self::V211 => 'https://web.archive.org/web/20160912130321id_/https://core.telegram.org/bots/api', 77 | self::V220 => 'https://web.archive.org/web/20161004150232id_/https://core.telegram.org/bots/api', 78 | self::V230 => 'https://web.archive.org/web/20161124162115id_/https://core.telegram.org/bots/api', 79 | self::V231 => 'https://web.archive.org/web/20161204181811id_/https://core.telegram.org/bots/api', 80 | self::V300 => 'https://web.archive.org/web/20170612094628id_/https://core.telegram.org/bots/api', 81 | self::V310 => 'https://web.archive.org/web/20170703123052id_/https://core.telegram.org/bots/api', 82 | self::V320 => 'https://web.archive.org/web/20170819054238id_/https://core.telegram.org/bots/api', 83 | self::V330 => 'https://web.archive.org/web/20170914060628id_/https://core.telegram.org/bots/api', 84 | self::V350 => 'https://web.archive.org/web/20171201065426id_/https://core.telegram.org/bots/api', 85 | self::V360 => 'https://web.archive.org/web/20180217001114id_/https://core.telegram.org/bots/api', 86 | self::V400 => 'https://web.archive.org/web/20180728174553id_/https://core.telegram.org/bots/api', 87 | self::V410 => 'https://web.archive.org/web/20180828155646id_/https://core.telegram.org/bots/api', 88 | self::V420 => 'https://web.archive.org/web/20190417160652id_/https://core.telegram.org/bots/api', 89 | self::V430 => 'https://web.archive.org/web/20190601122107id_/https://core.telegram.org/bots/api', 90 | self::V440 => 'https://web.archive.org/web/20190731114703id_/https://core.telegram.org/bots/api', 91 | self::V450 => 'https://web.archive.org/web/20200107090812id_/https://core.telegram.org/bots/api', 92 | self::V460 => 'https://web.archive.org/web/20200208225346id_/https://core.telegram.org/bots/api', 93 | self::V470 => 'https://web.archive.org/web/20200401052001id_/https://core.telegram.org/bots/api', 94 | self::V480 => 'https://web.archive.org/web/20200429054924id_/https://core.telegram.org/bots/api', 95 | self::V490 => 'https://web.archive.org/web/20200611131321id_/https://core.telegram.org/bots/api', 96 | self::V500 => 'https://web.archive.org/web/20201104151640id_/https://core.telegram.org/bots/api', 97 | self::V510 => 'https://web.archive.org/web/20210315055600id_/https://core.telegram.org/bots/api', 98 | self::V520 => 'https://web.archive.org/web/20210428195636id_/https://core.telegram.org/bots/api', 99 | self::V530 => 'https://web.archive.org/web/20210626142851id_/https://core.telegram.org/bots/api', 100 | self::V540 => 'https://web.archive.org/web/20211105152638id_/https://core.telegram.org/bots/api', 101 | self::V550 => 'https://web.archive.org/web/20211211002657id_/https://core.telegram.org/bots/api', 102 | self::V560 => 'https://web.archive.org/web/20220105131529id_/https://core.telegram.org/bots/api', 103 | self::V570 => 'https://web.archive.org/web/20220206103922id_/https://core.telegram.org/bots/api', 104 | self::V600 => 'https://web.archive.org/web/20220416143511id_/https://core.telegram.org/bots/api', 105 | self::V610 => 'https://web.archive.org/web/20220621093855id_/https://core.telegram.org/bots/api', 106 | self::V620 => 'https://web.archive.org/web/20220812143250id_/https://core.telegram.org/bots/api', 107 | self::V630 => 'https://web.archive.org/web/20221105155229id_/https://core.telegram.org/bots/api', 108 | self::V640 => 'https://web.archive.org/web/20221230181046id_/https://core.telegram.org/bots/api', 109 | self::V650 => 'https://web.archive.org/web/20230204145800id_/https://core.telegram.org/bots/api', 110 | self::V660 => 'https://web.archive.org/web/20230314174834id_/https://core.telegram.org/bots/api', 111 | self::V670 => 'https://web.archive.org/web/20230422225636id_/https://core.telegram.org/bots/api', 112 | self::V680 => 'https://web.archive.org/web/20230823081042id_/https://core.telegram.org/bots/api', 113 | self::V690 => 'https://web.archive.org/web/20230923182249id_/https://core.telegram.org/bots/api', 114 | self::V700 => 'https://web.archive.org/web/20240101113402id_/https://core.telegram.org/bots/api', 115 | self::V710 => 'https://web.archive.org/web/20240217084100id_/https://core.telegram.org/bots/api', 116 | self::V720 => 'https://web.archive.org/web/20240402153812id_/https://core.telegram.org/bots/api', 117 | self::V730 => 'https://web.archive.org/web/20240507163328id_/https://core.telegram.org/bots/api', 118 | self::V740 => 'https://web.archive.org/web/20240529172355id_/https://core.telegram.org/bots/api', 119 | self::V750 => 'https://web.archive.org/web/20240624102326id_/https://core.telegram.org/bots/api', 120 | self::V760 => 'https://web.archive.org/web/20240702102244id_/https://core.telegram.org/bots/api', 121 | self::LATEST => 'https://core.telegram.org/bots/api' 122 | ]; 123 | 124 | public static function getVersionFromText(string $text): string 125 | { 126 | $text = str_replace(['.', 'v'], ['', ''], strtolower($text)); 127 | $const = sprintf('%s::V%s', self::class, $text); 128 | if (defined($const)) { 129 | return constant($const); 130 | } 131 | return self::LATEST; 132 | } 133 | 134 | public static function getUrlFromText(string $text): string 135 | { 136 | $version = self::getVersionFromText($text); 137 | return self::URLS[$version] ?? self::URLS[self::LATEST]; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Parsers/Field.php: -------------------------------------------------------------------------------- 1 | 'int', 19 | 'Float' => 'float', 20 | 'String' => 'string', 21 | 'Boolean' => 'bool', 22 | 'True' => 'bool', 23 | 'False' => 'bool' 24 | ]; 25 | 26 | /** 27 | * @var string 28 | */ 29 | private string $name; 30 | /** 31 | * @var array 32 | */ 33 | private array $types; 34 | /** 35 | * @var FieldDescription 36 | */ 37 | private FieldDescription $description; 38 | /** 39 | * @var bool 40 | */ 41 | private bool $optional; 42 | /** 43 | * @var mixed 44 | */ 45 | private mixed $defaultValue; 46 | 47 | /** 48 | * @param string $name 49 | * @param string $types 50 | * @param bool $optional 51 | * @param string $description 52 | */ 53 | public function __construct(string $name, string $types, bool $optional, string $description) 54 | { 55 | $this->name = $name; 56 | $this->types = $this->parseTypesString($types); 57 | $this->optional = $optional; 58 | $this->description = new FieldDescription($description); 59 | } 60 | 61 | /** 62 | * @param string $type 63 | * @return string 64 | */ 65 | private function parseTypeString(string $type): string 66 | { 67 | if ($type == 'True') { 68 | $this->defaultValue = true; 69 | return self::TYPES['Boolean']; 70 | } elseif ($type == 'False') { 71 | $this->defaultValue = false; 72 | return self::TYPES['Boolean']; 73 | } 74 | $type = trim(str_replace('number', '', $type)); 75 | return trim(str_replace(array_keys(self::TYPES), array_values(self::TYPES), $type)); 76 | } 77 | 78 | /** 79 | * @param string $text 80 | * @return array 81 | */ 82 | private function parseTypesString(string $text): array 83 | { 84 | $types = []; 85 | $parts = explode(' or ', $text); 86 | foreach ($parts as $part) { 87 | $part = trim(str_replace(' and', ',', $part)); 88 | $arrays = 0; 89 | while (stripos($part, 'array of') === 0) { 90 | $part = substr($part, 9); 91 | $arrays++; 92 | } 93 | $pieces = explode(',', $part); 94 | foreach ($pieces as $index => $piece) { 95 | $pieces[$index] = $this->parseTypeString($piece); 96 | } 97 | $type = implode('|', $pieces); 98 | for ($i = 0; $i < $arrays; $i++) { 99 | $type = sprintf('Array<%s>', $type); 100 | } 101 | $types[] = $type; 102 | } 103 | return $types; 104 | } 105 | 106 | /** 107 | * @return string 108 | */ 109 | public function getName(): string 110 | { 111 | return $this->name; 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | public function getTypes(): array 118 | { 119 | return $this->types; 120 | } 121 | 122 | /** 123 | * @return bool 124 | */ 125 | public function isOptional(): bool 126 | { 127 | return $this->optional; 128 | } 129 | 130 | /** 131 | * @return mixed 132 | */ 133 | public function getDefaultValue(): mixed 134 | { 135 | if (!isset($this->defaultValue)) { 136 | $this->defaultValue = $this->description->getDefaultValue(); 137 | } 138 | return $this->defaultValue; 139 | } 140 | 141 | /** 142 | * @return array 143 | */ 144 | #[ArrayShape([ 145 | 'name' => "string", 146 | 'types' => "array", 147 | 'optional' => "bool", 148 | 'description' => "string", 149 | 'default' => "mixed" 150 | ])] public function toArray(): array 151 | { 152 | $result = [ 153 | 'name' => $this->name, 154 | 'types' => $this->types, 155 | 'optional' => $this->optional, 156 | 'description' => (string)$this->description, 157 | ]; 158 | $defaultValue = $this->getDefaultValue(); 159 | if (null !== $defaultValue) { 160 | $result['default'] = $defaultValue; 161 | } 162 | return $result; 163 | } 164 | 165 | } -------------------------------------------------------------------------------- /src/Parsers/FieldDescription.php: -------------------------------------------------------------------------------- 1 | dom = HtmlDomParser::str_get_html($description); 15 | foreach ($this->dom->find('.emoji') as $emoji) { 16 | $emoji->outerhtml .= $emoji->getAttribute('alt'); 17 | } 18 | } 19 | 20 | public function __toString() 21 | { 22 | return htmlspecialchars_decode($this->dom->text(), ENT_QUOTES); 23 | } 24 | 25 | public function getDefaultValue(): mixed 26 | { 27 | $description = (string)$this; 28 | if (stripos($description, 'must be') !== false) { 29 | $text = explode('must be ', $this->dom->html())[1] ?? ''; 30 | if (!empty($text)) { 31 | $text = explode(' ', $text)[0]; 32 | $dom = HtmlDomParser::str_get_html($text); 33 | $element = $dom->findOneOrFalse('em'); 34 | if ($element !== false) { 35 | return $element->text(); 36 | } 37 | } 38 | } 39 | $offset = stripos($description, 'defaults to'); 40 | if ($offset === false) { 41 | return null; 42 | } 43 | $description = substr($description, $offset + 12); 44 | $parts = explode(' ', $description, 2); 45 | $value = $parts[0]; 46 | if (str_ends_with($value, '.') or str_ends_with($value, ',')) { 47 | $value = substr($value, 0, -1); 48 | } 49 | if (str_starts_with($value, '“') and str_ends_with($value, '”')) { 50 | return str_replace(['“', '”'], ['', ''], $value); 51 | } 52 | if (is_numeric($value)) { 53 | return (int)$value; 54 | } 55 | if (strtolower($value) == 'true') { 56 | return true; 57 | } 58 | if (strtolower($value) == 'false') { 59 | return false; 60 | } 61 | if ($value === ucfirst($value)) { 62 | return $value; 63 | } 64 | return null; 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /src/Parsers/ObjectDescription.php: -------------------------------------------------------------------------------- 1 | types = self::parseReturnTypes($description); 25 | } 26 | 27 | /** 28 | * @param string $description 29 | * @return array 30 | */ 31 | private static function parseReturnTypes(string $description): array 32 | { 33 | $returnTypes = []; 34 | $phrases = explode('.', $description); 35 | $phrases = array_filter( 36 | $phrases, 37 | function ($phrase) { 38 | return (false !== stripos($phrase, 'returns') or false !== stripos($phrase, 'is returned')); 39 | } 40 | ); 41 | foreach ($phrases as $phrase) { 42 | $dom = HtmlDomParser::str_get_html($phrase); 43 | $a = $dom->findMulti('a'); 44 | $em = $dom->findMulti('em'); 45 | foreach ($a as $element) { 46 | if ($element->text() == 'Messages') { 47 | $returnTypes[] = 'Array'; 48 | continue; 49 | } 50 | $arrays = substr_count(strtolower($phrase), 'array'); 51 | $returnType = $element->text(); 52 | for ($i = 0; $i < $arrays; $i++) { 53 | $returnType = sprintf('Array<%s>', $returnType); 54 | } 55 | $returnTypes[] = $returnType; 56 | } 57 | foreach ($em as $element) { 58 | if (in_array($element->text(), ['False', 'force', 'Array'])) { 59 | continue; 60 | } 61 | $type = str_replace(['True', 'Int', 'String'], ['bool', 'int', 'string'], $element->text()); 62 | $returnTypes[] = $type; 63 | } 64 | } 65 | return $returnTypes; 66 | } 67 | 68 | /** 69 | * @return array 70 | */ 71 | public function getTypes(): array 72 | { 73 | return $this->types; 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /src/TgScraper.php: -------------------------------------------------------------------------------- 1 | version = $schema['version'] ?? '1.0.0'; 54 | $this->types = $schema['types']; 55 | $this->methods = $schema['methods']; 56 | } 57 | 58 | /** 59 | * @param LoggerInterface $logger 60 | * @param string $url 61 | * @return self 62 | * @throws Throwable 63 | */ 64 | public static function fromUrl(LoggerInterface $logger, string $url): self 65 | { 66 | $extractor = SchemaExtractor::fromUrl($logger, $url); 67 | $schema = $extractor->extract(); 68 | return new self($logger, $schema); 69 | } 70 | 71 | /** 72 | * @param LoggerInterface $logger 73 | * @param string $version 74 | * @return self 75 | * @throws Exception 76 | * @throws GuzzleException 77 | */ 78 | public static function fromVersion(LoggerInterface $logger, string $version = Versions::LATEST): self 79 | { 80 | $extractor = SchemaExtractor::fromVersion($logger, $version); 81 | $schema = $extractor->extract(); 82 | return new self($logger, $schema); 83 | } 84 | 85 | /** 86 | * @param array $schema 87 | * @return bool 88 | */ 89 | public static function validateSchema(array $schema): bool 90 | { 91 | return array_key_exists('version', $schema) and is_string($schema['version']) and 92 | array_key_exists('types', $schema) and is_array($schema['types']) and 93 | array_key_exists('methods', $schema) and is_array($schema['methods']); 94 | } 95 | 96 | /** 97 | * @param LoggerInterface $logger 98 | * @param string $yaml 99 | * @return TgScraper 100 | */ 101 | public static function fromYaml(LoggerInterface $logger, string $yaml): self 102 | { 103 | $data = Yaml::parse($yaml); 104 | return new self($logger, schema: $data); 105 | } 106 | 107 | /** 108 | * @param LoggerInterface $logger 109 | * @param string $json 110 | * @return TgScraper 111 | * @throws JsonException 112 | */ 113 | public static function fromJson(LoggerInterface $logger, string $json): self 114 | { 115 | $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); 116 | return new self($logger, schema: $data); 117 | } 118 | 119 | /** 120 | * @param string $directory 121 | * @param string $namespace 122 | * @return void 123 | * @throws Exception 124 | */ 125 | public function toStubs(string $directory = '', string $namespace = 'TelegramApi'): void 126 | { 127 | try { 128 | $directory = self::getTargetDirectory($directory); 129 | } catch (Exception $e) { 130 | $this->logger->critical( 131 | 'An exception occurred while trying to get the target directory: ' . $e->getMessage() 132 | ); 133 | throw $e; 134 | } 135 | $typesDir = $directory . '/Types'; 136 | if (!file_exists($typesDir)) { 137 | mkdir($typesDir, 0755); 138 | } 139 | try { 140 | $creator = new StubCreator($this->toArray(), $namespace); 141 | } catch (InvalidArgumentException $e) { 142 | $this->logger->critical( 143 | 'An exception occurred while trying to parse the schema: ' . $e->getMessage() 144 | ); 145 | throw $e; 146 | } 147 | $code = $creator->generateCode(); 148 | foreach ($code['types'] as $className => $type) { 149 | $this->logger->info('Generating class for Type: ' . $className); 150 | $filename = sprintf('%s/Types/%s.php', $directory, $className); 151 | file_put_contents($filename, $type); 152 | } 153 | file_put_contents($directory . '/API.php', $code['api']); 154 | } 155 | 156 | /** 157 | * @param string $path 158 | * @return string 159 | * @throws Exception 160 | */ 161 | public static function getTargetDirectory(string $path): string 162 | { 163 | $result = realpath($path); 164 | if (false === $result) { 165 | if (!mkdir($path, 0755, true)) { 166 | $path = getcwd() . '/gen'; 167 | if (!file_exists($path)) { 168 | mkdir($path, 0755, true); 169 | } 170 | } 171 | } 172 | $result = realpath($path); 173 | if (false === $result) { 174 | throw new Exception('Could not create target directory'); 175 | } 176 | return $result; 177 | } 178 | 179 | /** 180 | * @return array 181 | */ 182 | #[ArrayShape([ 183 | 'version' => "string", 184 | 'types' => "array", 185 | 'methods' => "array" 186 | ])] public function toArray(): array 187 | { 188 | return [ 189 | 'version' => $this->version, 190 | 'types' => $this->types, 191 | 'methods' => $this->methods 192 | ]; 193 | } 194 | 195 | /** 196 | * @return array 197 | */ 198 | public function toOpenApi(): array 199 | { 200 | $openapiTemplate = file_get_contents(self::TEMPLATES_DIRECTORY . '/openapi.json'); 201 | $openapiData = json_decode($openapiTemplate, true); 202 | $responsesTemplate = file_get_contents(self::TEMPLATES_DIRECTORY . '/responses.json'); 203 | $responses = json_decode($responsesTemplate, true); 204 | $openapi = new OpenApiGenerator($responses, $openapiData, $this->types, $this->methods); 205 | $openapi->setVersion($this->version); 206 | return $openapi->getData(); 207 | } 208 | 209 | 210 | /** 211 | * Thanks to davtur19 (https://github.com/davtur19/TuriBotGen/blob/master/postman.php) 212 | * @return array 213 | */ 214 | #[ArrayShape(['info' => "string[]", 'variable' => "string[]", 'item' => "array[]"])] 215 | public function toPostman(): array 216 | { 217 | $template = file_get_contents(self::TEMPLATES_DIRECTORY . '/postman.json'); 218 | $result = json_decode($template, true); 219 | $result['info']['version'] = $this->version; 220 | foreach ($this->methods as $method) { 221 | $formData = []; 222 | if (!empty($method['fields'])) { 223 | foreach ($method['fields'] as $field) { 224 | $data = [ 225 | 'key' => $field['name'], 226 | 'disabled' => $field['optional'], 227 | 'description' => sprintf( 228 | '%s. %s', 229 | $field['optional'] ? 'Optional' : 'Required', 230 | $field['description'] 231 | ), 232 | 'type' => 'text' 233 | ]; 234 | $default = $field['default'] ?? null; 235 | if (!empty($default)) { 236 | $data['value'] = (string)$default; 237 | } 238 | $formData[] = $data; 239 | } 240 | } 241 | $result['item'][] = [ 242 | 'name' => $method['name'], 243 | 'request' => [ 244 | 'method' => 'POST', 245 | 'body' => [ 246 | 'mode' => 'formdata', 247 | 'formdata' => $formData 248 | ], 249 | 'url' => [ 250 | 'raw' => 'https://api.telegram.org/bot{{token}}/' . $method['name'], 251 | 'protocol' => 'https', 252 | 'host' => [ 253 | 'api', 254 | 'telegram', 255 | 'org' 256 | ], 257 | 'path' => [ 258 | 'bot{{token}}', 259 | $method['name'] 260 | ] 261 | ], 262 | 'description' => $method['description'] 263 | ] 264 | ]; 265 | } 266 | return $result; 267 | } 268 | 269 | } 270 | -------------------------------------------------------------------------------- /templates/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Telegram Bot API", 5 | "description": "Auto-generated OpenAPI schema by TGScraper.", 6 | "version": "1.0.0" 7 | }, 8 | "servers": [ 9 | { 10 | "url": "https://api.telegram.org/bot{token}", 11 | "variables": { 12 | "token": { 13 | "default": "1234:AAbbcc", 14 | "description": "Bot's unique authentication token, given by @BotFather." 15 | } 16 | } 17 | } 18 | ], 19 | "externalDocs": { 20 | "description": "Official Telegram Bot API documentation.", 21 | "url": "https://core.telegram.org/bots/api" 22 | }, 23 | "components": { 24 | "responses": { 25 | "BadRequest": { 26 | "description": "Bad request, you have provided malformed data.", 27 | "content": { 28 | "application/json": { 29 | "schema": { 30 | "$ref": "#/components/schemas/Error" 31 | } 32 | } 33 | } 34 | }, 35 | "Unauthorized": { 36 | "description": "The authorization token is invalid or it has been revoked.", 37 | "content": { 38 | "application/json": { 39 | "schema": { 40 | "$ref": "#/components/schemas/Error" 41 | } 42 | } 43 | } 44 | }, 45 | "Forbidden": { 46 | "description": "This action is forbidden.", 47 | "content": { 48 | "application/json": { 49 | "schema": { 50 | "$ref": "#/components/schemas/Error" 51 | } 52 | } 53 | } 54 | }, 55 | "NotFound": { 56 | "description": "The specified resource was not found.", 57 | "content": { 58 | "application/json": { 59 | "schema": { 60 | "$ref": "#/components/schemas/Error" 61 | } 62 | } 63 | } 64 | }, 65 | "Conflict": { 66 | "description": "There is a conflict with another instance using webhook or polling.", 67 | "content": { 68 | "application/json": { 69 | "schema": { 70 | "$ref": "#/components/schemas/Error" 71 | } 72 | } 73 | } 74 | }, 75 | "TooManyRequests": { 76 | "description": "You're doing too many requests, retry after a while.", 77 | "content": { 78 | "application/json": { 79 | "schema": { 80 | "$ref": "#/components/schemas/Error" 81 | } 82 | } 83 | } 84 | }, 85 | "ServerError": { 86 | "description": "The bot API is experiencing some issues, try again later.", 87 | "content": { 88 | "application/json": { 89 | "schema": { 90 | "$ref": "#/components/schemas/Error" 91 | } 92 | } 93 | } 94 | }, 95 | "UnknownError": { 96 | "description": "An unknown error occurred.", 97 | "content": { 98 | "application/json": { 99 | "schema": { 100 | "$ref": "#/components/schemas/Error" 101 | } 102 | } 103 | } 104 | } 105 | }, 106 | "schemas": { 107 | "Response": { 108 | "type": "object", 109 | "description": "Represents the default response object.", 110 | "required": [ 111 | "ok" 112 | ], 113 | "properties": { 114 | "ok": { 115 | "type": "boolean" 116 | } 117 | } 118 | }, 119 | "Success": { 120 | "description": "Request was successful, the result is returned.", 121 | "allOf": [ 122 | { 123 | "$ref": "#/components/schemas/Response" 124 | }, 125 | { 126 | "type": "object", 127 | "required": [ 128 | "result" 129 | ], 130 | "properties": { 131 | "result": { 132 | "type": "object" 133 | } 134 | } 135 | } 136 | ] 137 | }, 138 | "Error": { 139 | "description": "Request was unsuccessful, so an error occurred.", 140 | "allOf": [ 141 | { 142 | "$ref": "#/components/schemas/Response" 143 | }, 144 | { 145 | "type": "object", 146 | "required": [ 147 | "error_code", 148 | "description" 149 | ], 150 | "properties": { 151 | "error_code": { 152 | "type": "integer" 153 | }, 154 | "description": { 155 | "type": "string" 156 | }, 157 | "parameters": { 158 | "$ref": "#/components/schemas/ResponseParameters" 159 | } 160 | } 161 | } 162 | ] 163 | } 164 | } 165 | }, 166 | "paths": {} 167 | } -------------------------------------------------------------------------------- /templates/postman.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Telegram Bot API", 4 | "description": "Auto-generated Postman collection by TGScraper.", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "version": "1.0.0" 7 | }, 8 | "variable": { 9 | "key": "token", 10 | "description": "Bot's unique authentication token, given by @BotFather.", 11 | "type": "string", 12 | "value": "1234:AAbbcc" 13 | }, 14 | "item": [] 15 | } -------------------------------------------------------------------------------- /templates/responses.json: -------------------------------------------------------------------------------- 1 | { 2 | "200": { 3 | "description": "Request was successful, the result is returned.", 4 | "content": { 5 | "application/json": { 6 | "schema": { 7 | "allOf": [ 8 | { 9 | "$ref": "#/components/schemas/Success" 10 | }, 11 | { 12 | "type": "object", 13 | "properties": { 14 | "result": {} 15 | } 16 | } 17 | ] 18 | } 19 | } 20 | } 21 | }, 22 | "400": { 23 | "$ref": "#/components/responses/BadRequest" 24 | }, 25 | "401": { 26 | "$ref": "#/components/responses/Unauthorized" 27 | }, 28 | "403": { 29 | "$ref": "#/components/responses/Forbidden" 30 | }, 31 | "404": { 32 | "$ref": "#/components/responses/NotFound" 33 | }, 34 | "409": { 35 | "$ref": "#/components/responses/Conflict" 36 | }, 37 | "429": { 38 | "$ref": "#/components/responses/TooManyRequests" 39 | }, 40 | "5XX": { 41 | "$ref": "#/components/responses/ServerError" 42 | }, 43 | "default": { 44 | "$ref": "#/components/responses/UnknownError" 45 | } 46 | } --------------------------------------------------------------------------------