├── .dockerignore ├── .editorconfig ├── .env.example ├── .gcloudignore ├── .github └── workflows │ ├── check.yml │ └── deploy.yml ├── .gitignore ├── .php.ini.d └── php.ini ├── .slugignore ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── app.yaml ├── bin └── console ├── composer.json ├── composer.lock ├── config.example.php ├── cron.yaml ├── crontab ├── dbcleanup.php ├── env_variables.example.yaml ├── fly.toml ├── logo.png ├── logo.xcf ├── nginx.inc.conf ├── php.ini ├── phpcs.xml.dist ├── public ├── .user.ini └── index.php ├── src ├── BotCore.php ├── Command │ ├── Admin │ │ ├── CleansessionsCommand.php │ │ └── StatsCommand.php │ ├── System │ │ ├── CallbackqueryCommand.php │ │ ├── ChoseninlineresultCommand.php │ │ ├── GenericmessageCommand.php │ │ └── InlinequeryCommand.php │ └── User │ │ └── StartCommand.php ├── Entity │ ├── Game.php │ ├── Game │ │ ├── Checkers.php │ │ ├── Connectfour.php │ │ ├── Elephantxo.php │ │ ├── Poolcheckers.php │ │ ├── Rockpaperscissors.php │ │ ├── Rockpaperscissorslizardspock.php │ │ ├── Russianroulette.php │ │ ├── Tictacfour.php │ │ └── Tictactoe.php │ └── TempFile.php ├── Exception │ ├── BotException.php │ ├── StorageException.php │ └── TelegramApiException.php ├── GameCore.php ├── Helper │ ├── Language.php │ └── Utilities.php ├── Storage │ ├── Driver │ │ ├── BotDB.php │ │ ├── File.php │ │ ├── Memcache.php │ │ ├── MySQL.php │ │ └── PostgreSQL.php │ └── Storage.php └── TelegramBot.php ├── start.sh ├── translations └── messages.pot └── worker.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything by default 2 | ** 3 | 4 | # Uningnore only what we need 5 | !.php.ini.d/* 6 | !bin/* 7 | !public/.user.ini 8 | !public/index.php 9 | !src/** 10 | !translations/*.pot 11 | !translations/*.po 12 | !vendor/ 13 | !config.php 14 | !composer.json 15 | !composer.lock 16 | !nginx.inc.conf 17 | !Procfile 18 | !start.sh 19 | !worker.sh 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = LF 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.php] 10 | indent_style = space 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Bot API token obtained from @BotFather 2 | BOT_TOKEN="" 3 | 4 | # Bot username (without '@' symbol) 5 | BOT_USERNAME="" 6 | 7 | # Webhook URL 8 | BOT_WEBHOOK="" 9 | 10 | # Secret variable used to secure the web hook 11 | BOT_SECRET="" 12 | 13 | # Optional: Admin's Telegram ID 14 | #BOT_ADMIN="" 15 | 16 | # Optional: Database connection DSN string 17 | #DATABASE_URL="" 18 | 19 | # Optional: php-telegram-bot's DB connection 20 | #DB_HOST="" 21 | #DB_USER="" 22 | #DB_PASS="" 23 | #DB_NAME="" 24 | 25 | # Optional: Bot data storage path (absolute) 26 | #DATA_PATH="" 27 | 28 | # Optional: Storage class to use 29 | #STORAGE_CLASS="" 30 | 31 | # Optional: Debug mode (will output a lot of data to logs/console) 32 | #DEBUG=true 33 | 34 | # Optional: Default language (translation file must exist - translations/messages.xx.po) 35 | #DEFAULT_LANGUAGE=en 36 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | #!include:.gitignore 2 | .* 3 | /logo.* 4 | 5 | # Unignore some files 6 | !/config.php 7 | 8 | # Files created by Github actions 9 | gha-creds-*.json 10 | DOCKER_ENV 11 | docker_tag 12 | *.log 13 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check code 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | check: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout the repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Cache Composer dependencies 18 | uses: actions/cache@v2 19 | with: 20 | path: /tmp/composer-cache 21 | key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} 22 | 23 | - name: Install PHP dependencies through Composer 24 | uses: php-actions/composer@v6 25 | with: 26 | version: 2 27 | php_version: 7.4 28 | args: --ignore-platform-reqs 29 | 30 | - name: Check code using PHP_CodeSniffer 31 | run: composer check-code 32 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Invoke deployment hook 14 | uses: distributhor/workflow-webhook@v2 15 | env: 16 | webhook_url: ${{ secrets.WEBHOOK_URL }} 17 | webhook_secret: ${{ secrets.WEBHOOK_SECRET }} 18 | webhook_auth: ${{ secrets.WEBHOOK_AUTH }} 19 | data: ${{ secrets.WEBHOOK_DATA }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | /vendor/ 3 | 4 | # Configuration files 5 | /config.php 6 | /.env 7 | /env_variables.yaml 8 | 9 | # Data directory 10 | /data/ 11 | 12 | # Other 13 | /translations/*.mo 14 | -------------------------------------------------------------------------------- /.php.ini.d/php.ini: -------------------------------------------------------------------------------- 1 | memory_limit = 16M 2 | 3 | extension=curl 4 | extension=gettext 5 | extension=intl 6 | extension=mbstring 7 | extension=pdo 8 | -------------------------------------------------------------------------------- /.slugignore: -------------------------------------------------------------------------------- 1 | /logo.* 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ------------- 3 | 4 | First, [fork](https://github.com/jacklul/inlinegamesbot/fork) this repository, checkout it locally and then install project dependencies with Composer - `composer install`. 5 | 6 | Now make all your changes and test them. 7 | 8 | To test the changes you will obviously need a bot, assuming you already have one - put the token and bot username in `.env` file for local development. 9 | 10 | The easiest way to test your changes is to run the bot with `getUpdates` method - use `php bin/console loop` command. 11 | 12 | Make sure your code is following PSR-2 coding standard - run [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) with `composer check-code` command. 13 | 14 | Now when all seems to be good push you changes to a new branch in your fork and then [create a pull request](https://github.com/jacklul/inlinegamesbot/compare) explaining all the changes. 15 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # For Heroku: 2 | web: vendor/bin/heroku-php-nginx -C nginx.inc.conf public/ 3 | 4 | # For fly.io through Heroku builder (with worker instead of cron) 5 | #web: ./start.sh 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inline Games [![License](https://img.shields.io/github/license/jacklul/inlinegamesbot.svg)](https://github.com/jacklul/inlinegamesbot/blob/master/LICENSE) [![Telegram](https://img.shields.io/badge/Telegram-%40inlinegamesbot-blue.svg)](https://telegram.me/inlinegamesbot) 2 | 3 | A Telegram bot that provides real-time multiplayer games that can be played in any chat. 4 | 5 | You can see the bot in action by messaging [@inlinegamesbot](https://telegram.me/inlinegamesbot). 6 | 7 | The bot is currently hosted at [DOM Cloud](https://domcloud.co). 8 | 9 | #### Currently available games: 10 | 11 | - Tic-Tac-Toe 12 | - Tic-Tac-Four ([@DO97](https://github.com/DO97)) 13 | - Elephant XO ([@DO97](https://github.com/DO97)) 14 | - Connect Four 15 | - Rock-Paper-Scissors 16 | - Rock-Paper-Scissors-Lizard-Spock ([@DO97](https://github.com/DO97)) 17 | - Russian Roulette 18 | - Checkers 19 | - Pool Checkers 20 | 21 | ## Deploying 22 | 23 | ### Heroku 24 | 25 |
26 | Instructions 27 | 28 | Use this button to begin deployment: 29 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/jacklul/inlinegamesbot) 30 | 31 | Assuming everything was entered correctly your bot should be instantly working - if it's not you should try running `php bin/console post-install` inside the app. 32 | 33 | You will also want to add **Heroku Scheduler** addon and set up a hourly task to run the following command to clean up expired games from the database: 34 | - `php bin/console cron` 35 | 36 | _If this command times out too fast try using something like this instead: `php -d max_execution_time=2700 bin/console cron`_ 37 | 38 |
39 | 40 | ### Google Cloud Platform 41 | 42 |
43 | Instructions 44 | 45 | - Install dependencies with `composer install` 46 | - Copy `env_variables.example.yaml` into `env_variables.yaml` and fill out the details 47 | - Run the deployment command: `gcloud app deploy --project YOUR-PROJECT-NAME-HERE app.yaml cron.yaml` 48 | - Visit `https://YOUR-PROJECT-NAME-HERE.appspot.com/admin?a=post-install` to perform post-install tasks 49 | 50 |
51 | 52 | ### Fly.io 53 | 54 |
55 | Instructions 56 | 57 | - `flyctl apps create` 58 | - `flyctl volumes create data --size=1` 59 | - `flyctl secrets set BOT_TOKEN=` 60 | - `flyctl secrets set BOT_USERNAME=` 61 | - `flyctl secrets set BOT_WEBHOOK=YOUR-APP-NAME.fly.dev` 62 | - `flyctl secrets set BOT_SECRET=` 63 | - If you want to use web+worker setup you have to replace `web:` line in `Procfile` 64 | - `flyctl deploy` 65 | 66 |
67 | 68 | ### DOM Cloud 69 | 70 |
71 | Instructions 72 | 73 | - Copy `.env.example` into `.env` and fill out the details 74 | - Upload `.env` and `crontab` to `/home//config` directory on the FTP 75 | - `crontab` will require modifications - use full paths to the script - e.g.: `/home//public_html/bin/console` 76 | - Run this deployment task: 77 | ``` 78 | source: 'https://github.com/jacklul/inlinegamesbot' 79 | commands: 80 | - 'test -f ../config/.env && cp -f ../config/.env .' 81 | - 'test -f ../config/config.php && cp -f ../config/config.php . || exit 0' 82 | - 'composer install --no-dev --optimize-autoloader --ignore-platform-reqs' 83 | - 'php bin/console install' 84 | - 'php bin/console set' 85 | - 'test -f ../config/crontab && cat ../config/crontab | crontab - || exit 0' 86 | features: 87 | - ssl 88 | - 'php 7.4' 89 | ``` 90 | 91 |
92 | 93 | ## Note on translations 94 | 95 | Translations support is implemented but it is not used mainly because translated text would be displayed to both players - this could be problematic in "gaming" groups - people setting language that other player can't understand! 96 | 97 | ## Contributing 98 | 99 | See [CONTRIBUTING](CONTRIBUTING.md) for more information. 100 | 101 | ## License 102 | 103 | See [LICENSE](LICENSE). 104 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Inline Games", 3 | "description": "A Telegram bot providing games that can be played in any chat.", 4 | "repository": "https://github.com/jacklul/inlinegamesbot", 5 | "logo": "https://raw.githubusercontent.com/jacklul/inlinegamesbot/master/logo.png", 6 | "stack": "heroku-20", 7 | "buildpacks": [ 8 | { 9 | "url": "https://github.com/heroku/heroku-buildpack-php" 10 | } 11 | ], 12 | "addons": [ 13 | "heroku-postgresql" 14 | ], 15 | "env": { 16 | "BOT_TOKEN": { 17 | "description": "Bot API token obtained from @BotFather", 18 | "value": "" 19 | }, 20 | "BOT_USERNAME": { 21 | "description": "Bot username (without '@' symbol)", 22 | "value": "" 23 | }, 24 | "BOT_WEBHOOK": { 25 | "description": "Webhook URL (YOURAPPNAME must match app name entered earlier)", 26 | "value": "https://YOURAPPNAME.herokuapp.com" 27 | }, 28 | "BOT_SECRET": { 29 | "description": "Secret variable used to secure the web hook", 30 | "generator": "secret" 31 | }, 32 | "BOT_ADMIN": { 33 | "description": "Admin's Telegram ID", 34 | "value": "", 35 | "required": false 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: php74 2 | 3 | entrypoint: serve --workers=15 public/index.php 4 | 5 | automatic_scaling: 6 | max_instances: 1 7 | max_idle_instances: 1 8 | max_concurrent_requests: 15 9 | 10 | # You might want to create an override config.php and set webhook.max_connections to match max_concurrent_requests & --workers value 11 | 12 | default_expiration: "1d" 13 | 14 | handlers: 15 | - url: /admin.* 16 | script: auto 17 | login: admin 18 | secure: always 19 | 20 | - url: /.* 21 | script: auto 22 | secure: always 23 | 24 | includes: 25 | - env_variables.yaml 26 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | use Bot\BotCore; 13 | 14 | /** 15 | * Composer autoloader 16 | */ 17 | require_once __DIR__ . '/../vendor/autoload.php'; 18 | 19 | /** 20 | * Run console interface 21 | */ 22 | try { 23 | $app = new BotCore(); 24 | $app->run(); 25 | } catch (\Throwable $e) { 26 | print $e; 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jacklul/inlinegamesbot", 3 | "type": "project", 4 | "description": "A Telegram bot providing games that can be played in any chat.", 5 | "license": "AGPL-3.0", 6 | "keywords": [ 7 | "telegram", 8 | "bot", 9 | "games" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Jack'lul", 14 | "email": "jacklulcat@gmail.com", 15 | "homepage": "https://jacklul.github.io", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^7.4", 21 | "ext-curl": "*", 22 | "ext-gettext": "*", 23 | "ext-intl": "*", 24 | "ext-json": "*", 25 | "ext-mbstring": "*", 26 | "ext-pdo": "*", 27 | "gettext/gettext": "^4.3", 28 | "jacklul/monolog-telegram": "^2.0", 29 | "longman/telegram-bot": "^0.78.0", 30 | "memcachier/php-memcache-sasl": "^1.0", 31 | "spatie/emoji": "^2.0", 32 | "vlucas/phpdotenv": "^5.0" 33 | }, 34 | "require-dev": { 35 | "squizlabs/php_codesniffer": "^3.2" 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Bot\\": "src/" 40 | } 41 | }, 42 | "config": { 43 | "sort-packages": true 44 | }, 45 | "scripts": { 46 | "check-code": [ 47 | "\"vendor/bin/phpcs\" -snp --standard=PSR2 --encoding=utf-8 --report-width=150 src/ bin/ public/" 48 | ], 49 | "post-install-cmd": [ 50 | "@php bin/console post-install" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /config.example.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | /** 12 | * Custom configuration 13 | */ 14 | 15 | return [ 16 | 'commands' => [ 17 | 'configs' => [ 18 | 'cleansessions' => [ 19 | 'clean_interval' => 3600, 20 | ], 21 | ], 22 | ], 23 | 'webhook' => [ 24 | 'max_connections' => 5, 25 | ], 26 | 'logging' => [ 27 | 'error' => DATA_PATH . '/logs/Error.log', 28 | ], 29 | ]; 30 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: Run cleanup task 3 | url: /admin?a=cron 4 | schedule: every hour 5 | -------------------------------------------------------------------------------- /crontab: -------------------------------------------------------------------------------- 1 | # Clean up older games hourly 2 | 0 * * * * php -d max_execution_time=2700 bin/console cron 3 | 4 | # Alternatively: just remove the records from the database (faster and lighter) 5 | #0 * * * * php dbcleanup.php 6 | -------------------------------------------------------------------------------- /dbcleanup.php: -------------------------------------------------------------------------------- 1 | load(); 8 | } 9 | 10 | $interval = 86400; 11 | 12 | if (file_exists(__DIR__ . '/config.php')) { 13 | $config = include_once __DIR__ . '/config.php'; 14 | $interval = $config['commands']['configs']['cleansessions']['clean_interval'] ?? $interval; 15 | } 16 | 17 | $dsn = parse_url(getenv('DATABASE_URL')); 18 | 19 | if (isset($dsn['host'])) { 20 | $pdo = new PDO('mysql:' . 'host=' . $dsn['host'] . ';port=' . $dsn['port'] . ';dbname=' . ltrim($dsn['path'], '/'), $dsn['user'], $dsn['pass']); 21 | $query = $pdo->query('DELETE FROM game WHERE updated_at < NOW() - INTERVAL ' . $interval . ' SECOND;'); 22 | echo $query->rowCount() . PHP_EOL; 23 | } else { 24 | die('DATABASE_URL is not set or is not a valid DSN string!' . PHP_EOL); 25 | } 26 | -------------------------------------------------------------------------------- /env_variables.example.yaml: -------------------------------------------------------------------------------- 1 | env_variables: 2 | DATABASE_URL: 'postgres://user:password@server:5432/database' 3 | BOT_TOKEN: '12345678:vS6dfn78gnfsb6Ssdfuy7d6s5d' 4 | BOT_USERNAME: 'yourawesomebot' 5 | BOT_WEBHOOK: 'https://APPNAME.appspot.com' 6 | BOT_SECRET: '' 7 | #BOT_ADMIN: '123456789' 8 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | app = "inlinegamesbot" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [build] 7 | builder = "heroku/buildpacks:20" 8 | #builder = "paketobuildpacks/builder:full" 9 | #buildpacks = ["gcr.io/paketo-buildpacks/php"] 10 | #[build.args] 11 | # BP_PHP_VERSION = "8.0" 12 | 13 | [deploy] 14 | release_command = "php bin/console post-install" 15 | 16 | [env] 17 | STORAGE_CLASS="Bot\\Storage\\Driver\\File" 18 | DATA_PATH="/data" 19 | LOG_NO_DATE_PREFIX=true 20 | WORKER_INTERVAL=60 21 | WORKER_MEMORY_LIMIT="64M" 22 | LIST_MEMORY_LIMIT="64M" 23 | # The following are required by Heroku builder 24 | PORT=8080 25 | DOCUMENT_ROOT="public/" 26 | WEB_CONCURRENCY=10 27 | 28 | [mounts] 29 | source="data" 30 | destination="/data" 31 | 32 | #[processes] 33 | # app = "php -S 0.0.0.0:8080 -t /workspace/public" 34 | # worker_app = "php bin/console worker" 35 | 36 | [experimental] 37 | allowed_public_ports = [] 38 | auto_rollback = true 39 | 40 | [[services]] 41 | http_checks = [] 42 | internal_port = 8080 43 | processes = ["app"] 44 | protocol = "tcp" 45 | script_checks = [] 46 | [services.concurrency] 47 | hard_limit = 25 48 | soft_limit = 20 49 | type = "connections" 50 | 51 | [[services.ports]] 52 | force_https = true 53 | handlers = ["http"] 54 | port = 80 55 | 56 | [[services.ports]] 57 | handlers = ["tls", "http"] 58 | port = 443 59 | 60 | [[services.tcp_checks]] 61 | grace_period = "1s" 62 | interval = "15s" 63 | restart_limit = 0 64 | timeout = "2s" 65 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacklul/inlinegamesbot/e815617fcd3e45898c115127a6eac1f4fca34aa0/logo.png -------------------------------------------------------------------------------- /logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacklul/inlinegamesbot/e815617fcd3e45898c115127a6eac1f4fca34aa0/logo.xcf -------------------------------------------------------------------------------- /nginx.inc.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | try_files = /index.php?$args; 3 | } 4 | -------------------------------------------------------------------------------- /php.ini: -------------------------------------------------------------------------------- 1 | memory_limit = 16M 2 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | PSR2 without line length warning 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/.user.ini: -------------------------------------------------------------------------------- 1 | memory_limit = 16M 2 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | use Bot\BotCore; 12 | 13 | /** 14 | * Handle webhook request only when it's a POST request 15 | */ 16 | if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST') { 17 | require_once __DIR__ . ' /../vendor/autoload.php'; 18 | 19 | try { 20 | $app = new BotCore(); 21 | $app->run(true); 22 | } catch (\Throwable $e) { 23 | // Prevent Telegram from retrying 24 | } 25 | } elseif (isset($_SERVER['GAE_VERSION']) && $_SERVER['PATH_INFO'] === '/admin') { 26 | require_once __DIR__.'/../vendor/autoload.php'; 27 | 28 | $app = new BotCore(); 29 | $app->run(); 30 | } else { 31 | header("Location: https://github.com/jacklul/inlinegamesbot"); // Redirect non-POST requests to Github repository 32 | } 33 | -------------------------------------------------------------------------------- /src/BotCore.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot; 12 | 13 | use Bot\Entity\TempFile; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Helper\Utilities; 17 | use Bot\Storage\Driver\File; 18 | use Bot\Storage\Storage; 19 | use Dotenv\Dotenv; 20 | use Exception; 21 | use Gettext\Translator; 22 | use GuzzleHttp\Client; 23 | use InvalidArgumentException; 24 | use Longman\TelegramBot\Entities\ServerResponse; 25 | use Longman\TelegramBot\Exception\TelegramException; 26 | use Longman\TelegramBot\Exception\TelegramLogException; 27 | use Longman\TelegramBot\Request; 28 | use Longman\TelegramBot\TelegramLog; 29 | use Monolog\Formatter\LineFormatter; 30 | use Monolog\Handler\DeduplicationHandler; 31 | use Monolog\Handler\FingersCrossedHandler; 32 | use Monolog\Handler\FingersCrossed\ErrorLevelActivationStrategy; 33 | use Monolog\Handler\StreamHandler; 34 | use Monolog\Logger; 35 | use Throwable; 36 | use jacklul\MonologTelegramHandler\TelegramFormatter; 37 | use jacklul\MonologTelegramHandler\TelegramHandler; 38 | 39 | define("ROOT_PATH", realpath(dirname(__DIR__))); 40 | define("APP_PATH", ROOT_PATH . '/bot'); 41 | define("SRC_PATH", ROOT_PATH . '/src'); 42 | 43 | /** 44 | * This is the master loader class, contains console commands and essential code for bootstrapping the bot 45 | */ 46 | class BotCore 47 | { 48 | /** 49 | * Commands 50 | * 51 | * @var array 52 | */ 53 | private static $commands = [ 54 | 'help' => [ 55 | 'function' => 'showHelp', 56 | 'description' => 'Shows this help message', 57 | ], 58 | 'set' => [ 59 | 'function' => 'setWebhook', 60 | 'description' => 'Set the webhook', 61 | 'require_telegram' => true, 62 | ], 63 | 'unset' => [ 64 | 'function' => 'deleteWebhook', 65 | 'description' => 'Delete the webhook', 66 | 'require_telegram' => true, 67 | ], 68 | 'info' => [ 69 | 'function' => 'webhookInfo', 70 | 'description' => 'Print webhookInfo request result', 71 | 'require_telegram' => true, 72 | ], 73 | 'install' => [ 74 | 'function' => 'installDb', 75 | 'description' => 'Execute database creation script', 76 | ], 77 | 'handle' => [ 78 | 'function' => 'handleWebhook', 79 | 'description' => 'Handle incoming webhook update', 80 | 'require_telegram' => true, 81 | ], 82 | 'run' => [ 83 | 'function' => 'handleLongPolling', 84 | 'description' => 'Run the bot using getUpdates in a loop', 85 | 'require_telegram' => true, 86 | ], 87 | 'cron' => [ 88 | 'function' => 'handleCron', 89 | 'description' => 'Run scheduled commands once', 90 | 'require_telegram' => true, 91 | ], 92 | 'worker' => [ 93 | 'function' => 'handleWorker', 94 | 'description' => 'Run scheduled commands every minute', 95 | 'require_telegram' => true, 96 | ], 97 | 'post-install' => [ 98 | 'function' => 'postComposerInstall', 99 | 'description' => 'Execute commands after composer install runs', 100 | 'hidden' => true, 101 | ], 102 | ]; 103 | 104 | /** 105 | * Config array 106 | * 107 | * @var array 108 | */ 109 | private $config = []; 110 | 111 | /** 112 | * TelegramBot object 113 | * 114 | * @var TelegramBot 115 | */ 116 | private $telegram; 117 | 118 | /** 119 | * Bot constructor 120 | * 121 | * @throws BotException 122 | */ 123 | public function __construct() 124 | { 125 | if (!defined('ROOT_PATH')) { 126 | throw new BotException('Root path not defined!'); 127 | } 128 | 129 | // Load environment variables from file if it exists 130 | if (class_exists(Dotenv::class) && file_exists(ROOT_PATH . '/.env')) { 131 | $dotenv = Dotenv::createUnsafeImmutable(ROOT_PATH); 132 | $dotenv->load(); 133 | } 134 | 135 | // Debug mode 136 | if (getenv('DEBUG')) { 137 | Utilities::setDebugPrint(); 138 | 139 | /*$logger = new Logger('console'); 140 | $logger->pushHandler((new ErrorLogHandler())->setFormatter(new LineFormatter('[%datetime%] %message% %context% %extra%', 'Y-m-d H:i:s', false, true))); 141 | 142 | Utilities::setDebugPrintLogger($logger);*/ 143 | } 144 | 145 | // Do not display errors by default 146 | ini_set('display_errors', (getenv('DEBUG') ? 1 : 0)); 147 | 148 | // Set timezone 149 | date_default_timezone_set(getenv('TIMEZONE') ?: 'UTC'); 150 | 151 | // Set custom data path if variable exists, otherwise do not use anything 152 | if (!empty($data_path = getenv('DATA_PATH'))) { 153 | define('DATA_PATH', str_replace('"', '', str_replace('./', ROOT_PATH . '/', $data_path))); 154 | } 155 | 156 | // gettext '__()' function must be initialized as all public messages are using it 157 | (new Translator())->register(); 158 | 159 | // Load DEFAULT config 160 | $this->loadDefaultConfig(); 161 | 162 | // Merge default config with user config 163 | $config_file = ROOT_PATH . '/config.php'; 164 | if (file_exists($config_file)) { 165 | /** @noinspection PhpIncludeInspection */ 166 | $config = include $config_file; 167 | 168 | if (is_array($config)) { 169 | $this->config = array_replace_recursive($this->config, $config); 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Load default config values 176 | */ 177 | private function loadDefaultConfig(): void 178 | { 179 | $this->config = [ 180 | 'api_key' => getenv('BOT_TOKEN'), 181 | 'bot_username' => getenv('BOT_USERNAME'), 182 | 'admins' => [(int)getenv('BOT_ADMIN') ?: 0], 183 | 'commands' => [ 184 | 'paths' => [ 185 | SRC_PATH . '/Command', 186 | ], 187 | 'configs' => [ 188 | 'cleansessions' => [ 189 | 'clean_interval' => 86400, 190 | ], 191 | ], 192 | ], 193 | 'webhook' => [ 194 | 'url' => getenv('BOT_WEBHOOK'), 195 | 'max_connections' => 100, 196 | 'allowed_updates' => [ 197 | 'message', 198 | 'inline_query', 199 | 'chosen_inline_result', 200 | 'callback_query', 201 | ], 202 | 'secret_token' => getenv('BOT_SECRET'), 203 | ], 204 | 'mysql' => [ 205 | 'host' => getenv('DB_HOST'), 206 | 'user' => getenv('DB_USER'), 207 | 'password' => getenv('DB_PASS'), 208 | 'database' => getenv('DB_NAME'), 209 | ], 210 | 'cron' => [ 211 | 'groups' => [ 212 | 'default' => [ 213 | '/cleansessions', 214 | ], 215 | ], 216 | ], 217 | ]; 218 | } 219 | 220 | /** 221 | * Run the bot 222 | * 223 | * @param bool $webhook 224 | * 225 | * @throws Throwable 226 | */ 227 | public function run(bool $webhook = false): void 228 | { 229 | if (is_bool($webhook) && $webhook === true) { 230 | $arg = 'handle'; // from webspace allow only handling webhook 231 | } elseif (isset($_SERVER['argv'][1])) { 232 | $arg = strtolower(trim($_SERVER['argv'][1])); 233 | } elseif (isset($_GET['a'])) { 234 | $arg = strtolower(trim($_GET['a'])); 235 | } 236 | 237 | try { 238 | if (!empty($arg) && isset(self::$commands[$arg]['function'])) { 239 | if (!$this->telegram instanceof TelegramBot && isset(self::$commands[$arg]['require_telegram']) && self::$commands[$arg]['require_telegram'] === true) { 240 | $this->initialize(); 241 | } 242 | 243 | $function = self::$commands[$arg]['function']; 244 | $this->$function(); 245 | } else { 246 | $this->showHelp(); 247 | 248 | if (!empty($arg)) { 249 | print PHP_EOL . 'Invalid parameter specified!' . PHP_EOL; 250 | } else { 251 | print PHP_EOL . 'No parameter specified!' . PHP_EOL; 252 | } 253 | } 254 | } catch (Throwable $e) { 255 | $ignored_errors = getenv('IGNORED_ERRORS'); 256 | 257 | if (!empty($ignored_errors) && !Utilities::isDebugPrintEnabled()) { 258 | $ignored_errors = explode(';', $ignored_errors); 259 | $ignored_errors = array_map('trim', $ignored_errors); 260 | 261 | foreach ($ignored_errors as $ignored_error) { 262 | if (strpos($e->getMessage(), $ignored_error) !== false) { 263 | return; 264 | } 265 | } 266 | } 267 | 268 | TelegramLog::error($e); 269 | throw $e; 270 | } 271 | } 272 | 273 | /** 274 | * Initialize Telegram object 275 | * 276 | * @throws TelegramException 277 | * @throws TelegramLogException 278 | * @throws InvalidArgumentException 279 | * @throws Exception 280 | */ 281 | private function initialize(): void 282 | { 283 | if ($this->telegram instanceof TelegramBot) { 284 | return; 285 | } 286 | 287 | Utilities::debugPrint('DEBUG MODE ACTIVE'); 288 | 289 | $this->telegram = new TelegramBot($this->config['api_key'], $this->config['bot_username']); 290 | $monolog = new Logger($this->config['bot_username']); 291 | 292 | if (isset($this->config['logging']['error'])) { 293 | $monolog->pushHandler((new StreamHandler($this->config['logging']['error'], Logger::ERROR))->setFormatter(new LineFormatter(null, null, true))); 294 | } 295 | 296 | if (isset($this->config['logging']['debug'])) { 297 | $monolog->pushHandler((new StreamHandler($this->config['logging']['debug'], Logger::ERROR))->setFormatter(new LineFormatter(null, null, true))); 298 | } 299 | 300 | if (isset($this->config['logging']['update'])) { 301 | $update_logger = new Logger( 302 | $this->config['bot_username'] . '_update', 303 | [ 304 | (new StreamHandler($this->config['logging']['update'], Logger::INFO))->setFormatter(new LineFormatter('%message%' . PHP_EOL)), 305 | ] 306 | ); 307 | } 308 | 309 | if (file_exists(ROOT_PATH . '/vendor/bin/heroku-php-nginx')) { 310 | $monolog->pushHandler( 311 | new FingersCrossedHandler( 312 | new StreamHandler('php://stderr'), 313 | new ErrorLevelActivationStrategy(Logger::WARNING) 314 | ) 315 | ); 316 | } 317 | 318 | if (isset($this->config['admins']) && !empty($this->config['admins'][0])) { 319 | $this->telegram->enableAdmins($this->config['admins']); 320 | 321 | $handler = new TelegramHandler($this->config['api_key'], (int)$this->config['admins'][0], Logger::ERROR); 322 | $handler->setFormatter(new TelegramFormatter()); 323 | 324 | $handler = new DeduplicationHandler($handler); 325 | $handler->setLevel(Utilities::isDebugPrintEnabled() ? Logger::DEBUG : Logger::ERROR); 326 | 327 | $monolog->pushHandler($handler); 328 | } 329 | 330 | if (!empty($monolog->getHandlers())) { 331 | TelegramLog::initialize($monolog, $update_logger ?? null); 332 | } 333 | 334 | if (isset($this->config['custom_http_client'])) { 335 | Request::setClient(new Client($this->config['custom_http_client'])); 336 | } 337 | 338 | if (isset($this->config['commands']['paths'])) { 339 | $this->telegram->addCommandsPaths($this->config['commands']['paths']); 340 | } 341 | 342 | if (isset($this->config['mysql']['host']) && !empty($this->config['mysql']['host'])) { 343 | $this->telegram->enableMySql($this->config['mysql']); 344 | } 345 | 346 | if (isset($this->config['paths']['download'])) { 347 | $this->telegram->setDownloadPath($this->config['paths']['download']); 348 | } 349 | 350 | if (isset($this->config['paths']['upload'])) { 351 | $this->telegram->setDownloadPath($this->config['paths']['upload']); 352 | } 353 | 354 | if (isset($this->config['commands']['configs'])) { 355 | foreach ($this->config['commands']['configs'] as $command => $config) { 356 | $this->telegram->setCommandConfig($command, $config); 357 | } 358 | } 359 | 360 | if (!empty($this->config['limiter']['enabled'])) { 361 | if (!empty($this->config['limiter']['options'])) { 362 | $this->telegram->enableLimiter($this->config['limiter']['options']); 363 | } else { 364 | $this->telegram->enableLimiter(); 365 | } 366 | } 367 | } 368 | 369 | /** 370 | * Display usage help 371 | */ 372 | private function showHelp(): void 373 | { 374 | if (PHP_SAPI !== 'cli') { 375 | print '
';
376 |         }
377 |         
378 |         print 'Bot Console' . ($this->config['bot_username'] ? ' (@' . $this->config['bot_username'] . ')' : '') . PHP_EOL . PHP_EOL;
379 |         print 'Available commands:' . PHP_EOL;
380 | 
381 |         $commands = '';
382 |         foreach (self::$commands as $command => $data) {
383 |             if (isset($data['hidden']) && $data['hidden'] === true) {
384 |                 continue;
385 |             }
386 | 
387 |             if (!empty($commands)) {
388 |                 $commands .= PHP_EOL;
389 |             }
390 | 
391 |             if (!isset($data['description'])) {
392 |                 $data['description'] = 'No description available';
393 |             }
394 | 
395 |             $commands .= ' ' . $command . str_repeat(' ', 10 - strlen($command)) . '- ' . trim($data['description']);
396 |         }
397 | 
398 |         print $commands . PHP_EOL;
399 | 
400 |         if (PHP_SAPI !== 'cli') {
401 |             print '
'; 402 | } 403 | } 404 | 405 | /** 406 | * Handle webhook method request 407 | * 408 | * @noinspection PhpUnusedPrivateMethodInspection 409 | * 410 | * @throws TelegramException 411 | */ 412 | private function handleWebhook(): void 413 | { 414 | if ($this->validateRequest()) { 415 | try { 416 | $this->telegram->handle(); 417 | } catch (TelegramException $e) { 418 | if (strpos($e->getMessage(), 'Telegram returned an invalid response') === false) { 419 | throw $e; 420 | } 421 | } 422 | } 423 | } 424 | 425 | /** 426 | * Validate request to check if it comes from the Telegram servers 427 | * and also does it contain a secret string 428 | * 429 | * @return bool 430 | */ 431 | private function validateRequest(): bool 432 | { 433 | if (PHP_SAPI !== 'cli') { 434 | $header_secret = null; 435 | foreach (getallheaders() as $name => $value) { 436 | if (stripos($name, 'X-Telegram-Bot-Api-Secret-Token') !== false) { 437 | $header_secret = $value; 438 | break; 439 | } 440 | } 441 | 442 | $secret = getenv('BOT_SECRET'); 443 | 444 | if (!isset($secret, $header_secret) || $secret !== $header_secret) { 445 | return false; 446 | } 447 | } 448 | 449 | return true; 450 | } 451 | 452 | /** 453 | * Set webhook 454 | * 455 | * @noinspection PhpUnusedPrivateMethodInspection 456 | * 457 | * @throws BotException 458 | * @throws TelegramException 459 | */ 460 | private function setWebhook(): void 461 | { 462 | if (empty($this->config['webhook']['url'])) { 463 | throw new BotException('Webhook URL is empty!'); 464 | } 465 | 466 | if (!isset($this->config['webhook']['secret_token'])) { 467 | throw new BotException('Secret is empty!'); 468 | } 469 | 470 | $result = $this->telegram->setWebhook($this->config['webhook']['url'], $this->config['webhook']); 471 | 472 | if ($result->isOk()) { 473 | print 'Webhook URL: ' . $this->config['webhook']['url'] . PHP_EOL; 474 | print $result->getDescription() . PHP_EOL; 475 | } else { 476 | print 'Request failed: ' . $result->getDescription() . PHP_EOL; 477 | } 478 | } 479 | 480 | /** 481 | * Delete webhook 482 | * 483 | * @noinspection PhpUnusedPrivateMethodInspection 484 | * 485 | * @throws TelegramException 486 | */ 487 | private function deleteWebhook(): void 488 | { 489 | $result = $this->telegram->deleteWebhook(); 490 | 491 | if ($result->isOk()) { 492 | print $result->getDescription() . PHP_EOL; 493 | } else { 494 | print 'Request failed: ' . $result->getDescription(); 495 | } 496 | } 497 | 498 | /** 499 | * Get webhook info 500 | * 501 | * @noinspection PhpUnusedPrivateMethodInspection 502 | */ 503 | private function webhookInfo(): void 504 | { 505 | $result = Request::getWebhookInfo(); 506 | 507 | if ($result->isOk()) { 508 | if (PHP_SAPI !== 'cli') { 509 | print '
' . print_r($result->getResult(), true) . '
' . PHP_EOL; 510 | } else { 511 | print print_r($result->getResult(), true) . PHP_EOL; 512 | } 513 | } else { 514 | print 'Request failed: ' . $result->getDescription() . PHP_EOL; 515 | } 516 | } 517 | 518 | /** 519 | * Handle getUpdates method 520 | * 521 | * @noinspection PhpUnusedPrivateMethodInspection 522 | * 523 | * @throws TelegramException 524 | */ 525 | private function handleLongPolling(): void 526 | { 527 | if (PHP_SAPI !== 'cli') { 528 | print 'Cannot run this from the webspace!' . PHP_EOL; 529 | 530 | return; 531 | } 532 | 533 | if (!isset($this->config['mysql']['host']) || empty($this->config['mysql']['host'])) { 534 | $this->telegram->useGetUpdatesWithoutDatabase(); 535 | } 536 | 537 | $dateTimePrefix = static function () { 538 | if (getenv('LOG_NO_DATE_PREFIX')) { 539 | return; 540 | } 541 | 542 | return '[' . date('Y-m-d H:i:s') . '] '; 543 | }; 544 | 545 | print $dateTimePrefix() . 'Running with getUpdates method...' . PHP_EOL; 546 | while (true) { 547 | set_time_limit(0); 548 | 549 | try { 550 | $server_response = $this->telegram->handleGetUpdates(); 551 | } catch (TelegramException $e) { 552 | if (strpos($e->getMessage(), 'Telegram returned an invalid response') !== false) { 553 | $server_response = new ServerResponse(['ok' => false, 'description' => 'Telegram returned an invalid response'], ''); 554 | } else { 555 | throw $e; 556 | } 557 | } 558 | 559 | if ($server_response->isOk()) { 560 | $update_count = count($server_response->getResult()); 561 | 562 | if ($update_count > 0) { 563 | print $dateTimePrefix() . 'Processed ' . $update_count . ' updates!' . ' (peak memory usage: ' . Utilities::formatBytes(memory_get_peak_usage()) . ')' . PHP_EOL; 564 | } 565 | } else { 566 | print $dateTimePrefix() . 'Failed to process updates!' . PHP_EOL; 567 | print 'Error: ' . $server_response->getDescription() . PHP_EOL; 568 | } 569 | 570 | if (function_exists('gc_collect_cycles')) { 571 | gc_collect_cycles(); 572 | } 573 | 574 | usleep(333333); 575 | } 576 | } 577 | 578 | /** 579 | * Handle worker process 580 | * 581 | * @noinspection PhpUnusedPrivateMethodInspection 582 | * 583 | * @throws BotException 584 | * @throws TelegramException 585 | */ 586 | private function handleWorker(): void 587 | { 588 | if (PHP_SAPI !== 'cli') { 589 | print 'Cannot run this from the webspace!' . PHP_EOL; 590 | 591 | return; 592 | } 593 | 594 | $dateTimePrefix = static function () { 595 | if (getenv('LOG_NO_DATE_PREFIX')) { 596 | return; 597 | } 598 | 599 | return '[' . date('Y-m-d H:i:s') . '] '; 600 | }; 601 | 602 | print $dateTimePrefix() . 'Initializing worker...' . PHP_EOL; 603 | 604 | $interval = 60; 605 | if (!empty($interval_user = getenv('WORKER_INTERVAL'))) { 606 | $interval = $interval_user; 607 | } 608 | 609 | if (!empty($memory_limit = getenv('WORKER_MEMORY_LIMIT'))) { 610 | ini_set('memory_limit', $memory_limit); 611 | } 612 | 613 | define('IN_WORKER', time()); 614 | 615 | $sleep_time = ceil($interval / 3); 616 | $last_run = time(); 617 | 618 | while (true) { 619 | set_time_limit(0); 620 | 621 | if (time() < $last_run + $interval) { 622 | $next_run = $last_run + $interval - time(); 623 | $sleep_time_this = $sleep_time; 624 | 625 | if ($next_run < $sleep_time_this) { 626 | $sleep_time_this = $next_run; 627 | } 628 | 629 | print $dateTimePrefix() . 'Next scheduled run in ' . $next_run . ' seconds, sleeping for ' . $sleep_time_this . ' seconds...' . PHP_EOL; 630 | 631 | if (function_exists('gc_collect_cycles')) { 632 | gc_collect_cycles(); 633 | } 634 | 635 | sleep($sleep_time_this); 636 | 637 | continue; 638 | } 639 | 640 | print $dateTimePrefix() . 'Running scheduled commands...' . PHP_EOL; 641 | 642 | try { 643 | $this->handleCron(); 644 | } catch (\Exception $e) { 645 | print $dateTimePrefix() . 'Caught exception: ' . $e->getMessage() . PHP_EOL; 646 | } 647 | 648 | $last_run = time(); 649 | } 650 | } 651 | 652 | /** 653 | * Run scheduled commands 654 | * 655 | * @throws BotException 656 | * @throws TelegramException 657 | */ 658 | private function handleCron(): void 659 | { 660 | $commands = []; 661 | 662 | $cronlock = new TempFile('cron'); 663 | if ($cronlock->getFile() === null) { 664 | exit("Couldn't obtain lockfile!" . PHP_EOL); 665 | } 666 | 667 | $file = $cronlock->getFile()->getPathname(); 668 | 669 | $fh = fopen($file, 'wb'); 670 | if (!$fh || !flock($fh, LOCK_EX | LOCK_NB)) { 671 | if (PHP_SAPI === 'cli') { 672 | print "There is already another cron task running in the background!" . PHP_EOL; 673 | } 674 | 675 | exit; 676 | } 677 | 678 | if (!empty($this->config['cron']['groups'])) { 679 | foreach ($this->config['cron']['groups'] as $command_group => $commands_in_group) { 680 | foreach ($commands_in_group as $command) { 681 | $commands[] = $command; 682 | } 683 | } 684 | } 685 | 686 | if (empty($commands)) { 687 | throw new BotException('No commands to run!'); 688 | } 689 | 690 | $this->telegram->runCommands($commands); 691 | 692 | if (flock($fh, LOCK_UN)) { 693 | fclose($fh); 694 | unlink($file); 695 | } 696 | } 697 | 698 | /** 699 | * Handle installing database structure 700 | * 701 | * @noinspection PhpUnusedPrivateMethodInspection 702 | * 703 | * @throws StorageException 704 | */ 705 | private function installDb(): void 706 | { 707 | /** @var File $storage_class */ 708 | $storage_class = Storage::getClass(); 709 | $storage_class_name = explode('\\', (string) $storage_class); 710 | 711 | print 'Installing storage structure (' . end($storage_class_name) . ')...' . PHP_EOL; 712 | 713 | if ($storage_class::createStructure()) { 714 | print 'Ok!' . PHP_EOL; 715 | } else { 716 | print 'Error!' . PHP_EOL; 717 | } 718 | } 719 | 720 | /** 721 | * Run some tasks after composer install 722 | * 723 | * @return void 724 | */ 725 | private function postComposerInstall(): void 726 | { 727 | if (!empty(getenv('STORAGE_CLASS')) || !empty(getenv('DATABASE_URL')) || !empty(getenv('DATA_PATH'))) { 728 | $this->installDb(); 729 | } 730 | 731 | if (!empty($this->config['api_key']) && !empty($this->config['bot_username']) && !empty($this->config['webhook']['url']) && !empty($this->config['webhook']['secret_token'])) { 732 | if (!$this->telegram instanceof TelegramBot) { 733 | $this->initialize(); 734 | } 735 | 736 | $this->setWebhook(); 737 | } 738 | } 739 | } 740 | -------------------------------------------------------------------------------- /src/Command/Admin/CleansessionsCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\Admin; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Exception\StorageException; 15 | use Bot\GameCore; 16 | use Bot\Helper\Utilities; 17 | use Bot\Storage\Driver\File; 18 | use Bot\Storage\Storage; 19 | use Longman\TelegramBot\Commands\AdminCommand; 20 | use Longman\TelegramBot\Entities\InlineKeyboard; 21 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 22 | use Longman\TelegramBot\Entities\ServerResponse; 23 | use Longman\TelegramBot\Exception\TelegramException; 24 | use Longman\TelegramBot\Request; 25 | 26 | /** 27 | * This command will clean games that were inactive for a longer period and 28 | * edit their message to indicate that they expired 29 | */ 30 | class CleansessionsCommand extends AdminCommand 31 | { 32 | protected $name = 'cleansessions'; 33 | protected $description = 'Clean old game messages and set them as empty'; 34 | protected $usage = '/cleansessions'; 35 | 36 | /** 37 | * @return ServerResponse 38 | * 39 | * @throws BotException 40 | * @throws StorageException 41 | * @throws TelegramException 42 | */ 43 | public function execute(): ServerResponse 44 | { 45 | $message = $this->getMessage(); 46 | $edited_message = $this->getUpdate()->getEditedMessage(); 47 | 48 | if ($edited_message) { 49 | $message = $edited_message; 50 | } 51 | 52 | $chat_id = $message->getFrom()->getId(); 53 | $bot_id = $this->getTelegram()->getBotId(); 54 | $text = trim($message->getText(true)); 55 | 56 | $data = []; 57 | $data['chat_id'] = $chat_id; 58 | 59 | $cleanInterval = $this->getConfig('clean_interval'); 60 | 61 | if (isset($text) && is_numeric($text) && $text > 0) { 62 | $cleanInterval = $text; 63 | } 64 | 65 | if (empty($cleanInterval)) { 66 | $cleanInterval = 86400; // 86400 seconds = 1 day 67 | } 68 | 69 | // Bug workaround: When run from the webhook and script just keeps going for too long Bot API will resend the update triggering the command again... and again... 70 | if (PHP_SAPI !== 'cli') { 71 | set_time_limit(10); 72 | } 73 | 74 | /** @var string $storage_class */ 75 | $storage_class = Storage::getClass(); 76 | 77 | if (class_exists($storage_class)) { 78 | $timeout = 10; 79 | do { 80 | try { 81 | $storage_init = $storage_class::initializeStorage(); 82 | } catch (StorageException $e) { 83 | if ($timeout < 0 || strpos($e, 'too many connections') === false) { 84 | throw $e; 85 | } 86 | } 87 | 88 | $timeout--; 89 | sleep(1); 90 | } while (!isset($storage_init)); 91 | 92 | if (!defined('IN_WORKER') && !empty($memory_limit = getenv('LIST_MEMORY_LIMIT'))) { 93 | ini_set('memory_limit', $memory_limit); 94 | } 95 | 96 | $inactive = $storage_class::listFromGame($cleanInterval); 97 | 98 | if (is_array($inactive) && count($inactive) > 0) { 99 | $chat_action_start = 0; 100 | $last_request_time = 0; 101 | $timelimit = ini_get('max_execution_time') > 0 ? ini_get('max_execution_time') : 59; 102 | $start_time = time(); 103 | 104 | $hours = floor($cleanInterval / 3600); 105 | $minutes = floor(($cleanInterval / 60) % 60); 106 | $seconds = $cleanInterval % 60; 107 | 108 | $data['text'] = 'Cleaning games older than ' . $hours . 'h ' . $minutes . 'm ' . $seconds . 's' . '... (time limit: ' . $timelimit . ' seconds)'; 109 | 110 | if ($chat_id != $bot_id) { 111 | Request::sendMessage($data); 112 | } elseif (PHP_SAPI === 'cli') { 113 | print $data['text'] . PHP_EOL; 114 | } 115 | 116 | $cleaned = 0; 117 | $edited = 0; 118 | 119 | foreach ($inactive as $inactive_game) { 120 | if (time() >= $start_time + $timelimit - 1) { 121 | Utilities::debugPrint('Time limit reached'); 122 | break; 123 | } 124 | 125 | if ($chat_id != $bot_id && $chat_action_start < strtotime('-5 seconds')) { 126 | Request::sendChatAction(['chat_id' => $chat_id, 'action' => 'typing']); 127 | $chat_action_start = time(); 128 | } 129 | 130 | $inactive_game['id'] = trim($inactive_game['id']); 131 | 132 | if (PHP_SAPI === 'cli' && $chat_id == $bot_id) { 133 | print 'Cleaning: \'' . $inactive_game['id'] . '\'' . PHP_EOL; 134 | } 135 | 136 | $game_data = $storage_class::selectFromGame($inactive_game['id']); 137 | 138 | if (isset($game_data['updated_at']) && $game_data['updated_at'] + $cleanInterval > time()) { 139 | continue; 140 | } 141 | 142 | if (isset($game_data['game_code'])) { 143 | $game = new GameCore($inactive_game['id'], $game_data['game_code'], $this); 144 | 145 | if ($game->canRun()) { 146 | while (time() <= $last_request_time) { 147 | Utilities::debugPrint('Delaying next request'); 148 | sleep(1); 149 | } 150 | 151 | $game_class = $game->getGame(); 152 | 153 | if (isset($game_data['game_data']['current_turn']) && $game_data['game_data']['current_turn'] === 'E') { 154 | $result = Request::editMessageReplyMarkup( 155 | [ 156 | 'inline_message_id' => $inactive_game['id'], 157 | 'reply_markup' => $this->createInlineKeyboard($game_data['game_code'], __('Create New Session')), 158 | ] 159 | ); 160 | } else { 161 | $result = Request::editMessageText( 162 | [ 163 | 'inline_message_id' => $inactive_game['id'], 164 | 'text' => '' . $game_class::getTitle() . '' . PHP_EOL . PHP_EOL . '' . __("This game session has expired.") . '', 165 | 'reply_markup' => $this->createInlineKeyboard($game_data['game_code']), 166 | 'parse_mode' => 'HTML', 167 | 'disable_web_page_preview' => true, 168 | ] 169 | ); 170 | } 171 | 172 | $last_request_time = time(); 173 | 174 | if (isset($result) && $result->isOk()) { 175 | $edited++; 176 | Utilities::debugPrint('Message edited successfully'); 177 | } else { 178 | Utilities::debugPrint('Failed to edit message: ' . (isset($result) ? $result->getDescription() : '')); 179 | } 180 | } 181 | } 182 | 183 | if ($storage_class::deleteFromGame($inactive_game['id'])) { 184 | $cleaned++; 185 | Utilities::debugPrint('Record removed from the database'); 186 | } 187 | } 188 | 189 | $data['text'] = 'Cleaned ' . $cleaned . ' games (edited ' . $edited . ' messages).'; 190 | } else { 191 | $data['text'] = 'Nothing to clean!'; 192 | } 193 | } else { 194 | $data['text'] = 'Error!'; 195 | } 196 | 197 | if ($chat_id != $bot_id) { 198 | return Request::sendMessage($data); 199 | } elseif (PHP_SAPI === 'cli') { 200 | print $data['text'] . PHP_EOL; 201 | } 202 | 203 | return Request::emptyResponse(); 204 | } 205 | 206 | /** 207 | * Create inline keyboard with button that creates the game session 208 | * 209 | * @param string $game_code 210 | * @param string|null $text 211 | * 212 | * @return InlineKeyboard 213 | * @throws TelegramException 214 | */ 215 | private function createInlineKeyboard(string $game_code, $text = null): InlineKeyboard 216 | { 217 | $inline_keyboard = [ 218 | [ 219 | new InlineKeyboardButton( 220 | [ 221 | 'text' => $text ?: __('Create'), 222 | 'callback_data' => $game_code . ';new', 223 | ] 224 | ), 225 | ], 226 | ]; 227 | 228 | return new InlineKeyboard(...$inline_keyboard); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Command/Admin/StatsCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\Admin; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Exception\StorageException; 15 | use Bot\GameCore; 16 | use Bot\Storage\Driver\File; 17 | use Bot\Storage\Storage; 18 | use Longman\TelegramBot\Commands\AdminCommand; 19 | use Longman\TelegramBot\Entities\InlineKeyboard; 20 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 21 | use Longman\TelegramBot\Entities\ServerResponse; 22 | use Longman\TelegramBot\Exception\TelegramException; 23 | use Longman\TelegramBot\Request; 24 | 25 | /** 26 | * Extremely simple stats command 27 | */ 28 | class StatsCommand extends AdminCommand 29 | { 30 | protected $name = 'stats'; 31 | protected $description = 'Display stats'; 32 | protected $usage = '/stats'; 33 | 34 | /** 35 | * @return ServerResponse 36 | * 37 | * @throws BotException 38 | * @throws StorageException 39 | * @throws TelegramException 40 | */ 41 | public function execute(): ServerResponse 42 | { 43 | $message = $this->getMessage(); 44 | $edited_message = $this->getUpdate()->getEditedMessage(); 45 | $callback_query = $this->getUpdate()->getCallbackQuery(); 46 | 47 | if ($edited_message) { 48 | $message = $edited_message; 49 | } 50 | 51 | $chat_id = null; 52 | $data_query = null; 53 | 54 | if ($message) { 55 | $chat_id = $message->getChat()->getId(); 56 | } elseif ($callback_query) { 57 | $chat_id = $callback_query->getMessage()->getChat()->getId(); 58 | 59 | $data_query = []; 60 | $data_query['callback_query_id'] = $callback_query->getId(); 61 | } 62 | 63 | /** @var File $storage_class */ 64 | $storage_class = Storage::getClass(); 65 | $storage_class::initializeStorage(); 66 | 67 | if (!empty($memory_limit = getenv('LIST_MEMORY_LIMIT'))) { 68 | ini_set('memory_limit', $memory_limit); 69 | } 70 | 71 | $games = $storage_class::listFromGame(); 72 | $stats = [ 73 | 'games' => [], 74 | 'games_5min' => [], 75 | 'total' => 0, 76 | '5min' => 0, 77 | ]; 78 | 79 | foreach ($games as $game) { 80 | $data = json_decode($game['data'], true); 81 | 82 | if (empty($data['game_code'])) { 83 | continue; 84 | } 85 | 86 | $game_obj = new GameCore($game['id'], $data['game_code'], $this); 87 | $game_class = $game_obj->getGame(); 88 | $game_title = $game_class::getTitle(); 89 | 90 | if (isset($stats['games'][$game_title])) { 91 | $stats['games'][$game_title] = $stats['games'][$game_title] + 1; 92 | } else { 93 | $stats['games'][$game_title] = 1; 94 | } 95 | 96 | if (!isset($stats['games_5min'][$game_title])) { 97 | $stats['games_5min'][$game_title] = 0; 98 | } 99 | 100 | if (strtotime($game['updated_at']) >= strtotime('-5 minutes')) { 101 | $stats['games_5min'][$game_title] = $stats['games_5min'][$game_title] + 1; 102 | $stats['5min']++; 103 | } 104 | 105 | $stats['total']++; 106 | } 107 | 108 | $stats['games']['All'] = $stats['total']; 109 | $stats['games_5min']['All'] = $stats['5min']; 110 | 111 | $output = '*Active sessions:*' . PHP_EOL; 112 | 113 | arsort($stats['games_5min']); 114 | 115 | foreach ($stats['games_5min'] as $game => $value) { 116 | $output .= ' ' . $game . ' – *' . ($stats['games_5min'][$game] ?? 0) . '* (*' . $stats['games'][$game] . '* total)' . PHP_EOL; 117 | } 118 | 119 | $data = []; 120 | $data['chat_id'] = $chat_id; 121 | $data['text'] = $output; 122 | $data['reply_markup'] = $this->createInlineKeyboard(); 123 | $data['parse_mode'] = 'Markdown'; 124 | 125 | if ($message) { 126 | return Request::sendMessage($data); 127 | } elseif ($callback_query) { 128 | $data['message_id'] = $callback_query->getMessage()->getMessageId(); 129 | $result = Request::editMessageText($data); 130 | 131 | if (!$result->isOk()) { 132 | $data_query['show_alert'] = true; 133 | $data_query['text'] = substr($result->getDescription(), 0, 200); 134 | } 135 | 136 | return Request::answerCallbackQuery($data_query); 137 | } 138 | 139 | return Request::emptyResponse(); 140 | } 141 | 142 | /** 143 | * Create inline keyboard that will refresh this message 144 | * 145 | * @return InlineKeyboard 146 | * @throws TelegramException 147 | */ 148 | private function createInlineKeyboard(): InlineKeyboard 149 | { 150 | $inline_keyboard = [ 151 | [ 152 | new InlineKeyboardButton( 153 | [ 154 | 'text' => 'Refresh', 155 | 'callback_data' => 'stats;refresh', 156 | ] 157 | ), 158 | ], 159 | ]; 160 | 161 | return new InlineKeyboard(...$inline_keyboard); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Command/System/CallbackqueryCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\System; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Exception\StorageException; 15 | use Bot\Exception\TelegramApiException; 16 | use Bot\GameCore; 17 | use Bot\Helper\Utilities; 18 | use Longman\TelegramBot\Commands\SystemCommand; 19 | use Longman\TelegramBot\Entities\ServerResponse; 20 | use Longman\TelegramBot\Exception\TelegramException; 21 | use Longman\TelegramBot\Request; 22 | use Throwable; 23 | 24 | /** 25 | * Handle button presses 26 | * 27 | * @noinspection PhpUndefinedClassInspection 28 | */ 29 | class CallbackqueryCommand extends SystemCommand 30 | { 31 | /** 32 | * Callback data before first ';' symbol -> command bind 33 | * 34 | * @var array 35 | */ 36 | private $aliases = [ 37 | 'stats' => 'stats', 38 | ]; 39 | 40 | /** 41 | * @return bool|ServerResponse|mixed 42 | * 43 | * @throws TelegramException 44 | * @throws BotException 45 | * @throws StorageException 46 | * @throws TelegramApiException 47 | * @throws Throwable 48 | */ 49 | public function execute(): ServerResponse 50 | { 51 | $callback_query = $this->getUpdate()->getCallbackQuery(); 52 | $data = $callback_query->getData(); 53 | 54 | Utilities::debugPrint('Data: ' . $data); 55 | 56 | $command = explode(';', $data)[0]; 57 | 58 | if (isset($this->aliases[$command]) && $this->getTelegram()->getCommandObject($this->aliases[$command])) { 59 | return $this->getTelegram()->executeCommand($this->aliases[$command]); 60 | } 61 | 62 | if (($inline_message_id = $callback_query->getInlineMessageId()) && $this->isDataValid($data)) { 63 | $game = new GameCore($inline_message_id, explode(';', $data)[0], $this); 64 | 65 | if ($game->canRun()) { 66 | return $game->run(); 67 | } 68 | } 69 | 70 | return Request::answerCallbackQuery( 71 | [ 72 | 'callback_query_id' => $callback_query->getId(), 73 | 'text' => __("Bad request!"), 74 | 'show_alert' => true, 75 | ] 76 | ); 77 | } 78 | 79 | /** 80 | * Validate callback data 81 | * 82 | * @param string $data 83 | * 84 | * @return bool 85 | */ 86 | private function isDataValid(string $data): bool 87 | { 88 | /** @noinspection CallableParameterUseCaseInTypeContextInspection */ 89 | $data = explode(';', $data); 90 | 91 | if (count($data) >= 2) { 92 | return true; 93 | } 94 | 95 | return false; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Command/System/ChoseninlineresultCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\System; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Exception\StorageException; 15 | use Bot\Exception\TelegramApiException; 16 | use Bot\GameCore; 17 | use Bot\Helper\Utilities; 18 | use Longman\TelegramBot\Commands\SystemCommand; 19 | use Longman\TelegramBot\Entities\ServerResponse; 20 | use Longman\TelegramBot\Exception\TelegramException; 21 | use Throwable; 22 | 23 | /** 24 | * Handle event when inline message is pasted into chat, instantly put a player into game 25 | */ 26 | class ChoseninlineresultCommand extends SystemCommand 27 | { 28 | /** 29 | * @return bool|ServerResponse 30 | * 31 | * @throws TelegramException 32 | * @throws BotException 33 | * @throws StorageException 34 | * @throws TelegramApiException 35 | * @throws Throwable 36 | */ 37 | public function execute(): ServerResponse 38 | { 39 | $chosen_inline_result = $this->getUpdate()->getChosenInlineResult(); 40 | 41 | if ($inline_message_id = $chosen_inline_result->getInlineMessageId()) { 42 | Utilities::debugPrint('Data: ' . $chosen_inline_result->getResultId()); 43 | 44 | $game = new GameCore($inline_message_id, $chosen_inline_result->getResultId(), $this); 45 | 46 | if ($game->canRun()) { 47 | return $game->run(); 48 | } 49 | } 50 | 51 | return parent::execute(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Command/System/GenericmessageCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\System; 12 | 13 | use Longman\TelegramBot\Commands\SystemCommand; 14 | use Longman\TelegramBot\Conversation; 15 | use Longman\TelegramBot\Entities\ServerResponse; 16 | use Longman\TelegramBot\Entities\User; 17 | use Longman\TelegramBot\Exception\TelegramException; 18 | use Longman\TelegramBot\Request; 19 | use Throwable; 20 | 21 | /** 22 | * Handle text messages 23 | * 24 | * @noinspection PhpUndefinedClassInspection 25 | */ 26 | class GenericmessageCommand extends SystemCommand 27 | { 28 | /** 29 | * @return mixed 30 | * 31 | * @throws TelegramException 32 | * @throws Throwable 33 | */ 34 | public function execute(): ServerResponse 35 | { 36 | $this->leaveGroupChat(); 37 | 38 | if ($this->getMessage()->getViaBot() instanceof User && $this->getMessage()->getViaBot()->getId() === $this->getTelegram()->getBotId()) { 39 | return Request::emptyResponse(); 40 | } 41 | 42 | $conversation = new Conversation( 43 | $this->getMessage()->getFrom()->getId(), 44 | $this->getMessage()->getChat()->getId() 45 | ); 46 | 47 | if ($conversation->exists() && ($command = $conversation->getCommand())) { 48 | return $this->telegram->executeCommand($command); 49 | } 50 | 51 | return $this->executeNoDb(); 52 | } 53 | 54 | /** 55 | * Leave group chats 56 | * 57 | * @return ServerResponse|null 58 | */ 59 | private function leaveGroupChat(): ?ServerResponse 60 | { 61 | if (getenv('DEBUG')) { 62 | return null; 63 | } 64 | 65 | if (!$this->getMessage()->getChat()->isPrivateChat()) { 66 | return Request::leaveChat( 67 | [ 68 | 'chat_id' => $this->getMessage()->getChat()->getId(), 69 | ] 70 | ); 71 | } 72 | 73 | return null; 74 | } 75 | 76 | /** 77 | * @return ServerResponse|void 78 | * @throws TelegramException 79 | */ 80 | public function executeNoDb(): ServerResponse 81 | { 82 | return Request::emptyResponse(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Command/System/InlinequeryCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\System; 12 | 13 | use Bot\Entity\Game; 14 | use DirectoryIterator; 15 | use Longman\TelegramBot\Commands\SystemCommand; 16 | use Longman\TelegramBot\Entities\InlineKeyboard; 17 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 18 | use Longman\TelegramBot\Entities\InlineQuery\InlineQueryResultArticle; 19 | use Longman\TelegramBot\Entities\InputMessageContent\InputTextMessageContent; 20 | use Longman\TelegramBot\Entities\ServerResponse; 21 | use Longman\TelegramBot\Exception\TelegramException; 22 | use Longman\TelegramBot\Request; 23 | 24 | /** 25 | * Handle incoming inline queries, shows game list no matter what user enters 26 | * 27 | * @noinspection PhpUndefinedClassInspection 28 | */ 29 | class InlinequeryCommand extends SystemCommand 30 | { 31 | /** 32 | * @return ServerResponse 33 | * 34 | * @throws TelegramException 35 | */ 36 | public function execute(): ServerResponse 37 | { 38 | $articles = []; 39 | 40 | foreach ($this->getGamesList() as $game) { 41 | /** @var Game $game_class */ 42 | if (class_exists($game_class = $game['class'])) { 43 | $articles[] = [ 44 | 'id' => $game_class::getCode(), 45 | 'title' => $game_class::getTitle() . (method_exists($game_class, 'getTitleExtra') ? ' ' . $game_class::getTitleExtra() : ''), 46 | 'description' => $game_class::getDescription(), 47 | 'input_message_content' => new InputTextMessageContent( 48 | [ 49 | 'message_text' => '' . $game_class::getTitle() . '' . PHP_EOL . PHP_EOL . '' . __('This game session is empty.') . '', 50 | 'parse_mode' => 'HTML', 51 | 'disable_web_page_preview' => true, 52 | ] 53 | ), 54 | 'reply_markup' => $this->createInlineKeyboard($game_class::getCode()), 55 | 'thumb_url' => $game_class::getImage(), 56 | ]; 57 | } 58 | } 59 | 60 | $array_article = []; 61 | foreach ($articles as $article) { 62 | $array_article[] = new InlineQueryResultArticle($article); 63 | } 64 | 65 | $result = Request::answerInlineQuery( 66 | [ 67 | 'inline_query_id' => $this->getUpdate()->getInlineQuery()->getId(), 68 | 'cache_time' => 60, 69 | 'results' => '[' . implode(',', $array_article) . ']', 70 | 'switch_pm_text' => 'Help', 71 | 'switch_pm_parameter' => 'start', 72 | ] 73 | ); 74 | 75 | return $result; 76 | } 77 | 78 | /** 79 | * Get games list 80 | * 81 | * @return array 82 | */ 83 | private function getGamesList(): array 84 | { 85 | $games = []; 86 | if (is_dir(SRC_PATH . '/Entity/Game')) { 87 | foreach (new DirectoryIterator(SRC_PATH . '/Entity/Game') as $file) { 88 | if (!$file->isDir() && !$file->isDot() && $file->getExtension() === 'php') { 89 | /** @var Game $game_class */ 90 | $game_class = '\Bot\Entity\Game\\' . basename($file->getFilename(), '.php'); 91 | 92 | $games[] = [ 93 | 'class' => $game_class, 94 | 'order' => $game_class::getOrder(), 95 | ]; 96 | } 97 | } 98 | } 99 | 100 | usort( 101 | $games, 102 | static function ($item1, $item2) { 103 | return $item1['order'] <=> $item2['order']; 104 | } 105 | ); 106 | 107 | return $games; 108 | } 109 | 110 | /** 111 | * Create inline keyboard with button that creates the game session 112 | * 113 | * @param string $game_code 114 | * 115 | * @return InlineKeyboard 116 | * @throws TelegramException 117 | */ 118 | private function createInlineKeyboard(string $game_code): InlineKeyboard 119 | { 120 | $inline_keyboard = [ 121 | [ 122 | new InlineKeyboardButton( 123 | [ 124 | 'text' => __('Create'), 125 | 'callback_data' => $game_code . ';new', 126 | ] 127 | ), 128 | ], 129 | ]; 130 | 131 | return new InlineKeyboard(...$inline_keyboard); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Command/User/StartCommand.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Command\User; 12 | 13 | use Longman\TelegramBot\Commands\UserCommand; 14 | use Longman\TelegramBot\Entities\InlineKeyboard; 15 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 16 | use Longman\TelegramBot\Entities\ServerResponse; 17 | use Longman\TelegramBot\Exception\TelegramException; 18 | use Longman\TelegramBot\Request; 19 | use Spatie\Emoji\Emoji; 20 | 21 | /** 22 | * Start command... 23 | * 24 | * @noinspection PhpUndefinedClassInspection 25 | */ 26 | class StartCommand extends UserCommand 27 | { 28 | /** 29 | * @return mixed 30 | * 31 | * @throws TelegramException 32 | */ 33 | public function execute(): ServerResponse 34 | { 35 | $message = $this->getUpdate()->getMessage(); 36 | $edited_message = $this->getUpdate()->getEditedMessage(); 37 | $callback_query = $this->getUpdate()->getCallbackQuery(); 38 | 39 | if ($edited_message) { 40 | $message = $edited_message; 41 | } 42 | 43 | $chat_id = null; 44 | $data_query = null; 45 | 46 | if ($message) { 47 | if (!$message->getChat()->isPrivateChat()) { 48 | return Request::emptyResponse(); 49 | } 50 | 51 | $chat_id = $message->getChat()->getId(); 52 | } elseif ($callback_query) { 53 | $chat_id = $callback_query->getMessage()->getChat()->getId(); 54 | 55 | $data_query = []; 56 | $data_query['callback_query_id'] = $callback_query->getId(); 57 | } 58 | 59 | $text = Emoji::wavingHand() . ' '; 60 | $text .= '' . __('Hi!') . '' . PHP_EOL; 61 | $text .= __('To begin, start a message with {USAGE} in any of your chats or click the {BUTTON} button and then select a chat to play in.', ['{USAGE}' => '\'@' . $this->getTelegram()->getBotUsername() . ' ...\'', '{BUTTON}' => '\'' . __('Play') . '\'']); 62 | 63 | $data = [ 64 | 'chat_id' => $chat_id, 65 | 'text' => $text, 66 | 'parse_mode' => 'HTML', 67 | 'disable_web_page_preview' => true, 68 | 'reply_markup' => new InlineKeyboard( 69 | [ 70 | new InlineKeyboardButton( 71 | [ 72 | 'text' => __('Play') . ' ' . Emoji::gameDie(), 73 | 'switch_inline_query' => Emoji::gameDie(), 74 | ] 75 | ), 76 | ] 77 | ), 78 | ]; 79 | 80 | if ($message) { 81 | return Request::sendMessage($data); 82 | } elseif ($callback_query) { 83 | $data['message_id'] = $callback_query->getMessage()->getMessageId(); 84 | $result = Request::editMessageText($data); 85 | Request::answerCallbackQuery($data_query); 86 | 87 | return $result; 88 | } 89 | 90 | return Request::emptyResponse(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Entity/Game/Connectfour.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | use Bot\Entity\Game; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Helper\Utilities; 17 | use Longman\TelegramBot\Entities\ServerResponse; 18 | use Longman\TelegramBot\Exception\TelegramException; 19 | use Spatie\Emoji\Emoji; 20 | 21 | /** 22 | * Connect Four 23 | */ 24 | class Connectfour extends Game 25 | { 26 | /** 27 | * Game unique ID 28 | * 29 | * @var string 30 | */ 31 | protected static $code = 'c4'; 32 | 33 | /** 34 | * Game name / title 35 | * 36 | * @var string 37 | */ 38 | protected static $title = 'Connect Four'; 39 | 40 | /** 41 | * Game description 42 | * 43 | * @var string 44 | */ 45 | protected static $description = 'Connect Four is a connection game in which the players take turns dropping colored discs from the top into a seven-column, six-row vertically suspended grid.'; 46 | 47 | /** 48 | * Game thumbnail image 49 | * 50 | * @var string 51 | */ 52 | protected static $image = 'http://i.imgur.com/KgH8blx.jpg'; 53 | 54 | /** 55 | * Order on the games list 56 | * 57 | * @var int 58 | */ 59 | protected static $order = 10; 60 | 61 | /** 62 | * Base starting board 63 | * 64 | * @var array 65 | */ 66 | protected static $board = [ 67 | ['', '', '', '', '', '', ''], 68 | ['', '', '', '', '', '', ''], 69 | ['', '', '', '', '', '', ''], 70 | ['', '', '', '', '', '', ''], 71 | ['', '', '', '', '', '', ''], 72 | ['', '', '', '', '', '', ''], 73 | ]; 74 | 75 | /** 76 | * Game handler 77 | * 78 | * @return ServerResponse 79 | * 80 | * @throws BotException 81 | * @throws TelegramException 82 | * @throws StorageException 83 | */ 84 | protected function gameAction(): ServerResponse 85 | { 86 | if ($this->getCurrentUserId() !== $this->getUserId('host') && $this->getCurrentUserId() !== $this->getUserId('guest')) { 87 | return $this->answerCallbackQuery(__("You're not in this game!"), true); 88 | } 89 | 90 | $data = &$this->data['game_data']; 91 | 92 | $this->defineSymbols(); 93 | 94 | $callbackquery_data = $this->manager->getUpdate()->getCallbackQuery()->getData(); 95 | $callbackquery_data = explode(';', $callbackquery_data); 96 | 97 | $command = $callbackquery_data[1]; 98 | 99 | if (isset($callbackquery_data[2])) { 100 | $args = explode('-', $callbackquery_data[2]); 101 | } 102 | 103 | if ($command === 'start') { 104 | if (isset($data['settings']) && $data['settings']['X'] == 'host') { 105 | $data['settings']['X'] = 'guest'; 106 | $data['settings']['O'] = 'host'; 107 | } else { 108 | $data['settings']['X'] = 'host'; 109 | $data['settings']['O'] = 'guest'; 110 | } 111 | 112 | $data['current_turn'] = 'X'; 113 | $data['board'] = static::$board; 114 | 115 | Utilities::debugPrint('Game initialization'); 116 | } elseif (!isset($args)) { 117 | Utilities::debugPrint('No move data received'); 118 | } 119 | 120 | if (isset($data['current_turn']) && $data['current_turn'] == 'E') { 121 | return $this->answerCallbackQuery(__("This game has ended!", true)); 122 | } 123 | 124 | if ($this->getCurrentUserId() !== $this->getUserId($data['settings'][$data['current_turn']]) && $command !== 'start') { 125 | return $this->answerCallbackQuery(__("It's not your turn!"), true); 126 | } 127 | 128 | $this->max_y = count($data['board']); 129 | $this->max_x = count($data['board'][0]); 130 | 131 | Utilities::isDebugPrintEnabled() && Utilities::debugPrint('BOARD: ' . $this->max_x . ' - ' . $this->max_y); 132 | 133 | if (isset($args)) { 134 | for ($y = $this->max_y - 1; $y >= 0; $y--) { 135 | if (isset($data['board'][$y][$args[1]])) { 136 | if ($data['board'][$y][$args[1]] === '') { 137 | $data['board'][$y][$args[1]] = $data['current_turn']; 138 | 139 | if ($data['current_turn'] == 'X') { 140 | $data['current_turn'] = 'O'; 141 | } elseif ($data['current_turn'] == 'O') { 142 | $data['current_turn'] = 'X'; 143 | } 144 | 145 | break; 146 | } 147 | 148 | if ($y === 0) { 149 | return $this->answerCallbackQuery(__("Invalid move!"), true); 150 | } 151 | } else { 152 | Utilities::debugPrint('Invalid move data: ' . ($args[0]) . ' - ' . ($y)); 153 | 154 | return $this->answerCallbackQuery(__("Invalid move!"), true); 155 | } 156 | } 157 | 158 | Utilities::debugPrint($data['current_turn'] . ' placed at ' . ($args[1]) . ' - ' . ($y)); 159 | } 160 | 161 | $isOver = $this->isGameOver($data['board']); 162 | $gameOutput = ''; 163 | 164 | if (!empty($isOver) && in_array($isOver, ['X', 'O'])) { 165 | $gameOutput = Emoji::trophy() . ' ' . __("{PLAYER} won!", ['{PLAYER}' => '' . $this->getUserMention($data['settings'][$isOver]) . '']) . ''; 166 | } elseif ($isOver == 'T') { 167 | $gameOutput = Emoji::chequeredFlag() . ' ' . __("Game ended with a draw!") . ''; 168 | } 169 | 170 | if (!empty($isOver) && in_array($isOver, ['X', 'O', 'T'])) { 171 | $data['current_turn'] = 'E'; 172 | } else { 173 | $gameOutput = Emoji::playButton() . ' ' . $this->getUserMention($data['settings'][$data['current_turn']]) . ' (' . $this->symbols[$data['current_turn']] . ')'; 174 | } 175 | 176 | if ($this->saveData($this->data)) { 177 | return $this->editMessage( 178 | $this->getUserMention('host') . ' (' . (($data['settings']['X'] == 'host') ? $this->symbols['X'] : $this->symbols['O']) . ')' . ' vs. ' . $this->getUserMention('guest') . ' (' . (($data['settings']['O'] == 'guest') ? $this->symbols['O'] : $this->symbols['X']) . ')' . PHP_EOL . PHP_EOL . $gameOutput, 179 | $this->gameKeyboard($data['board'], $isOver) 180 | ); 181 | } 182 | 183 | return parent::gameAction(); 184 | } 185 | 186 | /** 187 | * Define game symbols 188 | */ 189 | protected function defineSymbols(): void 190 | { 191 | $this->symbols['empty'] = Emoji::whiteCircle(); 192 | 193 | $this->symbols['X'] = Emoji::blueCircle(); 194 | $this->symbols['O'] = Emoji::redCircle(); 195 | 196 | $this->symbols['X_won'] = Emoji::largeBlueDiamond(); 197 | $this->symbols['O_won'] = Emoji::largeOrangeDiamond(); 198 | 199 | $this->symbols['X_lost'] = Emoji::blackCircle(); 200 | $this->symbols['O_lost'] = Emoji::blackCircle(); 201 | } 202 | 203 | /** 204 | * Check whenever game is over 205 | * 206 | * @param array $board 207 | * 208 | * @return string 209 | */ 210 | protected function isGameOver(array &$board): ?string 211 | { 212 | $empty = 0; 213 | for ($x = 0; $x <= $this->max_x; $x++) { 214 | for ($y = 0; $y <= $this->max_y; $y++) { 215 | if (isset($board[$x][$y]) && $board[$x][$y] == '') { 216 | $empty++; 217 | } 218 | 219 | if (isset($board[$x][$y]) && isset($board[$x][$y + 1]) && isset($board[$x][$y + 2]) && isset($board[$x][$y + 3])) { 220 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x][$y + 1] && $board[$x][$y] == $board[$x][$y + 2] && $board[$x][$y] == $board[$x][$y + 3]) { 221 | $winner = $board[$x][$y]; 222 | $board[$x][$y + 1] = $board[$x][$y] . '_won'; 223 | $board[$x][$y + 2] = $board[$x][$y] . '_won'; 224 | $board[$x][$y + 3] = $board[$x][$y] . '_won'; 225 | $board[$x][$y] = $board[$x][$y] . '_won'; 226 | 227 | return $winner; 228 | } 229 | } 230 | 231 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y]) && isset($board[$x + 2][$y]) && isset($board[$x + 3][$y])) { 232 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y] && $board[$x][$y] == $board[$x + 2][$y] && $board[$x][$y] == $board[$x + 3][$y]) { 233 | $winner = $board[$x][$y]; 234 | $board[$x + 1][$y] = $board[$x][$y] . '_won'; 235 | $board[$x + 2][$y] = $board[$x][$y] . '_won'; 236 | $board[$x + 3][$y] = $board[$x][$y] . '_won'; 237 | $board[$x][$y] = $board[$x][$y] . '_won'; 238 | 239 | return $winner; 240 | } 241 | } 242 | 243 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y + 1]) && isset($board[$x + 2][$y + 2]) && isset($board[$x + 3][$y + 3])) { 244 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y + 1] && $board[$x][$y] == $board[$x + 2][$y + 2] && $board[$x][$y] == $board[$x + 3][$y + 3]) { 245 | $winner = $board[$x][$y]; 246 | $board[$x + 1][$y + 1] = $board[$x][$y] . '_won'; 247 | $board[$x + 2][$y + 2] = $board[$x][$y] . '_won'; 248 | $board[$x + 3][$y + 3] = $board[$x][$y] . '_won'; 249 | $board[$x][$y] = $board[$x][$y] . '_won'; 250 | 251 | return $winner; 252 | } 253 | } 254 | 255 | if (isset($board[$x][$y]) && isset($board[$x - 1][$y + 1]) && isset($board[$x - 2][$y + 2]) && isset($board[$x - 3][$y + 3])) { 256 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x - 1][$y + 1] && $board[$x][$y] == $board[$x - 2][$y + 2] && $board[$x][$y] == $board[$x - 3][$y + 3]) { 257 | $winner = $board[$x][$y]; 258 | $board[$x - 1][$y + 1] = $board[$x][$y] . '_won'; 259 | $board[$x - 2][$y + 2] = $board[$x][$y] . '_won'; 260 | $board[$x - 3][$y + 3] = $board[$x][$y] . '_won'; 261 | $board[$x][$y] = $board[$x][$y] . '_won'; 262 | 263 | return $winner; 264 | } 265 | } 266 | } 267 | } 268 | 269 | if ($empty == 0) { 270 | return 'T'; 271 | } 272 | 273 | return null; 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/Entity/Game/Elephantxo.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | /** 14 | * Elephant XO 15 | */ 16 | class Elephantxo extends Tictactoe 17 | { 18 | /** 19 | * Game unique ID 20 | * 21 | * @var string 22 | */ 23 | protected static $code = 'exo'; 24 | 25 | /** 26 | * Game name / title 27 | * 28 | * @var string 29 | */ 30 | protected static $title = 'Elephant XO'; 31 | 32 | /** 33 | * Game description 34 | * 35 | * @var string 36 | */ 37 | protected static $description = 'Elephant XO is a game for two players, X and O, who take turns marking the spaces in a 8×8 grid.'; 38 | 39 | /** 40 | * Game thumbnail image 41 | * 42 | * @var string 43 | */ 44 | protected static $image = 'https://i.imgur.com/tv2dmtq.png'; 45 | 46 | /** 47 | * Order on the games list 48 | * 49 | * @var int 50 | */ 51 | protected static $order = 3; 52 | 53 | /** 54 | * Base starting board 55 | * 56 | * @var array 57 | */ 58 | protected static $board = [ 59 | ['', '', '', '', '', '', '', ''], 60 | ['', '', '', '', '', '', '', ''], 61 | ['', '', '', '', '', '', '', ''], 62 | ['', '', '', '', '', '', '', ''], 63 | ['', '', '', '', '', '', '', ''], 64 | ['', '', '', '', '', '', '', ''], 65 | ['', '', '', '', '', '', '', ''], 66 | ['', '', '', '', '', '', '', ''], 67 | ]; 68 | 69 | /** 70 | * Check whenever game is over 71 | * 72 | * @param array $board 73 | * 74 | * @return string 75 | */ 76 | protected function isGameOver(array &$board): ?string 77 | { 78 | $empty = 0; 79 | for ($x = 0; $x <= $this->max_x; $x++) { 80 | for ($y = 0; $y <= $this->max_y; $y++) { 81 | if (isset($board[$x][$y]) && $board[$x][$y] == '') { 82 | $empty++; 83 | } 84 | 85 | if (isset($board[$x][$y]) && isset($board[$x][$y + 1]) && isset($board[$x][$y + 2]) && isset($board[$x][$y + 3]) && isset($board[$x][$y + 4])) { 86 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x][$y + 1] && $board[$x][$y] == $board[$x][$y + 2] && $board[$x][$y] == $board[$x][$y + 3] && $board[$x][$y] == $board[$x][$y + 4]) { 87 | $winner = $board[$x][$y]; 88 | $board[$x][$y + 1] = $board[$x][$y] . '_won'; 89 | $board[$x][$y + 2] = $board[$x][$y] . '_won'; 90 | $board[$x][$y + 3] = $board[$x][$y] . '_won'; 91 | $board[$x][$y + 4] = $board[$x][$y] . '_won'; 92 | $board[$x][$y] = $board[$x][$y] . '_won'; 93 | 94 | return $winner; 95 | } 96 | } 97 | 98 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y]) && isset($board[$x + 2][$y]) && isset($board[$x + 3][$y]) && isset($board[$x + 4][$y])) { 99 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y] && $board[$x][$y] == $board[$x + 2][$y] && $board[$x][$y] == $board[$x + 3][$y] && $board[$x][$y] == $board[$x + 4][$y]) { 100 | $winner = $board[$x][$y]; 101 | $board[$x + 1][$y] = $board[$x][$y] . '_won'; 102 | $board[$x + 2][$y] = $board[$x][$y] . '_won'; 103 | $board[$x + 3][$y] = $board[$x][$y] . '_won'; 104 | $board[$x + 4][$y] = $board[$x][$y] . '_won'; 105 | $board[$x][$y] = $board[$x][$y] . '_won'; 106 | 107 | return $winner; 108 | } 109 | } 110 | 111 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y + 1]) && isset($board[$x + 2][$y + 2]) && isset($board[$x + 3][$y + 3]) && isset($board[$x + 4][$y + 4])) { 112 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y + 1] && $board[$x][$y] == $board[$x + 2][$y + 2] && $board[$x][$y] == $board[$x + 3][$y + 3] && $board[$x][$y] == $board[$x + 4][$y + 4]) { 113 | $winner = $board[$x][$y]; 114 | $board[$x + 1][$y + 1] = $board[$x][$y] . '_won'; 115 | $board[$x + 2][$y + 2] = $board[$x][$y] . '_won'; 116 | $board[$x + 3][$y + 3] = $board[$x][$y] . '_won'; 117 | $board[$x + 4][$y + 4] = $board[$x][$y] . '_won'; 118 | $board[$x][$y] = $board[$x][$y] . '_won'; 119 | 120 | return $winner; 121 | } 122 | } 123 | 124 | if (isset($board[$x][$y]) && isset($board[$x - 1][$y + 1]) && isset($board[$x - 2][$y + 2]) && isset($board[$x - 3][$y + 3]) && isset($board[$x - 4][$y + 4])) { 125 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x - 1][$y + 1] && $board[$x][$y] == $board[$x - 2][$y + 2] && $board[$x][$y] == $board[$x - 3][$y + 3] && $board[$x][$y] == $board[$x - 4][$y + 4]) { 126 | $winner = $board[$x][$y]; 127 | $board[$x - 1][$y + 1] = $board[$x][$y] . '_won'; 128 | $board[$x - 2][$y + 2] = $board[$x][$y] . '_won'; 129 | $board[$x - 3][$y + 3] = $board[$x][$y] . '_won'; 130 | $board[$x - 4][$y + 4] = $board[$x][$y] . '_won'; 131 | $board[$x][$y] = $board[$x][$y] . '_won'; 132 | 133 | return $winner; 134 | } 135 | } 136 | } 137 | } 138 | 139 | if ($empty == 0) { 140 | return 'T'; 141 | } 142 | 143 | return null; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Entity/Game/Rockpaperscissors.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | use Bot\Entity\Game; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Helper\Utilities; 17 | use Longman\TelegramBot\Entities\InlineKeyboard; 18 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 19 | use Longman\TelegramBot\Entities\ServerResponse; 20 | use Longman\TelegramBot\Exception\TelegramException; 21 | use Spatie\Emoji\Emoji; 22 | 23 | /** 24 | * Rock-Paper-Scissors 25 | */ 26 | class Rockpaperscissors extends Game 27 | { 28 | /** 29 | * Game unique ID 30 | * 31 | * @var string 32 | */ 33 | protected static $code = 'rps'; 34 | 35 | /** 36 | * Game name / title 37 | * 38 | * @var string 39 | */ 40 | protected static $title = 'Rock-Paper-Scissors'; 41 | 42 | /** 43 | * Game description 44 | * 45 | * @var string 46 | */ 47 | protected static $description = 'Rock-paper-scissors is game in which each player simultaneously forms one of three shapes with an outstretched hand.'; 48 | 49 | /** 50 | * Game thumbnail image 51 | * 52 | * @var string 53 | */ 54 | protected static $image = 'https://i.imgur.com/1H8HI7n.png'; 55 | 56 | /** 57 | * Order on the games list 58 | * 59 | * @var int 60 | */ 61 | protected static $order = 20; 62 | 63 | /** 64 | * Game handler 65 | * 66 | * @return ServerResponse 67 | * 68 | * @throws BotException 69 | * @throws TelegramException 70 | * @throws StorageException 71 | */ 72 | protected function gameAction(): ServerResponse 73 | { 74 | if ($this->getCurrentUserId() !== $this->getUserId('host') && $this->getCurrentUserId() !== $this->getUserId('guest')) { 75 | return $this->answerCallbackQuery(__("You're not in this game!"), true); 76 | } 77 | 78 | $data = &$this->data['game_data']; 79 | 80 | $this->defineSymbols(); 81 | 82 | $callbackquery_data = $this->manager->getUpdate()->getCallbackQuery()->getData(); 83 | $callbackquery_data = explode(';', $callbackquery_data); 84 | 85 | $command = $callbackquery_data[1]; 86 | 87 | $arg = $callbackquery_data[2] ?? null; 88 | 89 | if ($command === 'start') { 90 | $data['host_pick'] = ''; 91 | $data['guest_pick'] = ''; 92 | $data['host_wins'] = 0; 93 | $data['guest_wins'] = 0; 94 | $data['round'] = 1; 95 | $data['current_turn'] = ''; 96 | 97 | Utilities::debugPrint('Game initialization'); 98 | } elseif ($arg === null) { 99 | Utilities::debugPrint('No move data received'); 100 | } 101 | 102 | if (isset($data['current_turn']) && $data['current_turn'] == 'E') { 103 | return $this->answerCallbackQuery(__("This game has ended!"), true); 104 | } 105 | 106 | Utilities::debugPrint('Argument: ' . $arg); 107 | 108 | if (isset($arg)) { 109 | if (in_array($arg, $this->symbols['valid'])) { 110 | if ($this->getCurrentUserId() === $this->getUserId('host') && $data['host_pick'] == '') { 111 | $data['host_pick'] = $arg; 112 | } elseif ($this->getCurrentUserId() === $this->getUserId('guest') && $data['guest_pick'] == '') { 113 | $data['guest_pick'] = $arg; 114 | } 115 | 116 | if ($this->saveData($this->data)) { 117 | Utilities::isDebugPrintEnabled() && Utilities::debugPrint($this->getCurrentUserMention() . ' picked ' . $arg); 118 | } 119 | } else { 120 | Utilities::debugPrint('Invalid move data: ' . $arg); 121 | 122 | return $this->answerCallbackQuery(__("Invalid move!"), true); 123 | } 124 | } 125 | 126 | $isOver = false; 127 | $gameOutput = ''; 128 | $hostPick = ''; 129 | $guestPick = ''; 130 | 131 | if ($data['host_pick'] != '' && $data['guest_pick'] != '') { 132 | $isOver = $this->isGameOver($data['host_pick'], $data['guest_pick']); 133 | 134 | if (in_array($isOver, ['X', 'O', 'T'])) { 135 | $data['round'] += 1; 136 | 137 | if ($isOver == 'X') { 138 | $data['host_wins'] = $data['host_wins'] + 1; 139 | 140 | $gameOutput = Emoji::sportsMedal() . ' ' . __("{PLAYER} won this round!", ['{PLAYER}' => '' . $this->getUserMention('host') . '']) . '' . PHP_EOL; 141 | } elseif ($isOver == 'O') { 142 | $data['guest_wins'] = $data['guest_wins'] + 1; 143 | 144 | $gameOutput = Emoji::sportsMedal() . ' ' . __("{PLAYER} won this round!", ['{PLAYER}' => '' . $this->getUserMention('guest') . '']) . '' . PHP_EOL; 145 | } else { 146 | $gameOutput = Emoji::chequeredFlag() . ' ' . __("This round ended with a draw!") . '' . PHP_EOL; 147 | } 148 | } 149 | 150 | $hostPick = ' (' . $this->symbols[$data['host_pick'] . '_short'] . ')'; 151 | $guestPick = ' (' . $this->symbols[$data['guest_pick'] . '_short'] . ')'; 152 | } 153 | 154 | if (($data['host_wins'] >= 3 && $data['host_wins'] > $data['guest_wins']) || $data['host_wins'] >= $data['guest_wins'] + 3 || ($data['round'] > 5 && $data['host_wins'] > $data['guest_wins'])) { 155 | $gameOutput = Emoji::trophy() . ' ' . __("{PLAYER} won the game!", ['{PLAYER}' => '' . $this->getUserMention('host') . '']) . ''; 156 | 157 | $data['current_turn'] = 'E'; 158 | } elseif (($data['guest_wins'] >= 3 && $data['guest_wins'] > $data['host_wins']) || $data['guest_wins'] >= $data['host_wins'] + 3 || ($data['round'] > 5 && $data['guest_wins'] > $data['host_wins'])) { 159 | $gameOutput = Emoji::trophy() . ' ' . __("{PLAYER} won the game!", ['{PLAYER}' => '' . $this->getUserMention('guest') . '']) . ''; 160 | 161 | $data['current_turn'] = 'E'; 162 | } else { 163 | $gameOutput .= '' . __("Round {ROUND} - make your picks!", ['{ROUND}' => $data['round']]) . ''; 164 | 165 | if ($data['host_pick'] != '' && $data['guest_pick'] === '') { 166 | $gameOutput .= PHP_EOL . '' . __("Waiting for:") . ' ' . $this->getUserMention('guest'); 167 | } elseif ($data['guest_pick'] != '' && $data['host_pick'] === '') { 168 | $gameOutput .= PHP_EOL . '' . __("Waiting for:") . ' ' . $this->getUserMention('host'); 169 | } else { 170 | $data['host_pick'] = ''; 171 | $data['guest_pick'] = ''; 172 | } 173 | 174 | $isOver = false; 175 | } 176 | 177 | if ($this->saveData($this->data)) { 178 | return $this->editMessage( 179 | $this->getUserMention('host') . (($data['host_wins'] > 0 || $data['guest_wins'] > 0) ? ' (' . $data['host_wins'] . ')' : '') . $hostPick . ' vs. ' . $this->getUserMention('guest') . (($data['guest_wins'] > 0 || $data['host_wins'] > 0) ? ' (' . $data['guest_wins'] . ')' : '') . $guestPick . PHP_EOL . PHP_EOL . $gameOutput, 180 | $this->customGameKeyboard($isOver) 181 | ); 182 | } 183 | 184 | return parent::gameAction(); 185 | } 186 | 187 | /** 188 | * Define game symbols (emojis) 189 | */ 190 | protected function defineSymbols(): void 191 | { 192 | $this->symbols['R'] = 'ROCK'; 193 | $this->symbols['R_short'] = Emoji::raisedFist(); 194 | $this->symbols['P'] = 'PAPER'; 195 | $this->symbols['P_short'] = Emoji::raisedHand(); 196 | $this->symbols['S'] = 'SCISSORS'; 197 | $this->symbols['S_short'] = Emoji::victoryHand(); 198 | $this->symbols['valid'] = ['R', 'P', 'S']; 199 | } 200 | 201 | /** 202 | * Check whenever game is over 203 | * 204 | * @param string $x 205 | * @param string $y 206 | * 207 | * @return string 208 | */ 209 | protected function isGameOver(string $x, string $y): ?string 210 | { 211 | if ($x == 'P' && $y == 'R') { 212 | return 'X'; 213 | } 214 | 215 | if ($y == 'P' && $x == 'R') { 216 | return 'O'; 217 | } 218 | 219 | if ($x == 'R' && $y == 'S') { 220 | return 'X'; 221 | } 222 | 223 | if ($y == 'R' && $x == 'S') { 224 | return 'O'; 225 | } 226 | 227 | if ($x == 'S' && $y == 'P') { 228 | return 'X'; 229 | } 230 | 231 | if ($y == 'S' && $x == 'P') { 232 | return 'O'; 233 | } 234 | 235 | if ($y == $x) { 236 | return 'T'; 237 | } 238 | 239 | return null; 240 | } 241 | 242 | /** 243 | * Keyboard for game in progress 244 | * 245 | * @param bool $isOver 246 | * 247 | * @return InlineKeyboard 248 | * @throws BotException 249 | */ 250 | protected function customGameKeyboard(bool $isOver = false): InlineKeyboard 251 | { 252 | if (!$isOver) { 253 | $inline_keyboard[] = [ 254 | new InlineKeyboardButton( 255 | [ 256 | 'text' => $this->symbols['R'] . ' ' . $this->symbols['R_short'], 257 | 'callback_data' => self::getCode() . ';game;R', 258 | ] 259 | ), 260 | new InlineKeyboardButton( 261 | [ 262 | 'text' => $this->symbols['P'] . ' ' . $this->symbols['P_short'], 263 | 'callback_data' => self::getCode() . ';game;P', 264 | ] 265 | ), 266 | new InlineKeyboardButton( 267 | [ 268 | 'text' => $this->symbols['S'] . ' ' . $this->symbols['S_short'], 269 | 'callback_data' => self::getCode() . ';game;S', 270 | ] 271 | ), 272 | ]; 273 | } else { 274 | $inline_keyboard[] = [ 275 | new InlineKeyboardButton( 276 | [ 277 | 'text' => __('Play again!'), 278 | 'callback_data' => self::getCode() . ';start', 279 | ] 280 | ), 281 | ]; 282 | } 283 | 284 | if (getenv('DEBUG') && $this->getCurrentUserId() == getenv('BOT_ADMIN')) { 285 | $inline_keyboard[] = [ 286 | new InlineKeyboardButton( 287 | [ 288 | 'text' => 'DEBUG: ' . 'Restart', 289 | 'callback_data' => self::getCode() . ';start', 290 | ] 291 | ), 292 | ]; 293 | } 294 | 295 | $inline_keyboard[] = [ 296 | new InlineKeyboardButton( 297 | [ 298 | 'text' => __('Quit'), 299 | 'callback_data' => self::getCode() . ';quit', 300 | ] 301 | ), 302 | new InlineKeyboardButton( 303 | [ 304 | 'text' => __('Kick'), 305 | 'callback_data' => self::getCode() . ';kick', 306 | ] 307 | ), 308 | ]; 309 | 310 | return new InlineKeyboard(...$inline_keyboard); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/Entity/Game/Rockpaperscissorslizardspock.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | use Bot\Exception\BotException; 14 | use Longman\TelegramBot\Entities\InlineKeyboard; 15 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 16 | use Spatie\Emoji\Emoji; 17 | 18 | /** 19 | * Rock-Paper-Scissors-Lizard-Spock 20 | */ 21 | class Rockpaperscissorslizardspock extends Rockpaperscissors 22 | { 23 | /** 24 | * Game unique ID 25 | * 26 | * @var string 27 | */ 28 | protected static $code = 'rpsls'; 29 | 30 | /** 31 | * Game name / title 32 | * 33 | * @var string 34 | */ 35 | protected static $title = 'Rock-Paper-Scissors-Lizard-Spock'; 36 | 37 | /** 38 | * Game description 39 | * 40 | * @var string 41 | */ 42 | protected static $description = 'Rock-paper-scissors-lizard-spock is a game in which each player simultaneously forms one of five shapes with an outstretched hand.'; 43 | 44 | /** 45 | * Game thumbnail image 46 | * 47 | * @var string 48 | */ 49 | protected static $image = 'https://i.imgur.com/vSMnT88.png'; 50 | 51 | /** 52 | * Order on the games list 53 | * 54 | * @var int 55 | */ 56 | protected static $order = 21; 57 | 58 | /** 59 | * Define game symbols (emojis) 60 | */ 61 | protected function defineSymbols(): void 62 | { 63 | $this->symbols['R'] = 'ROCK'; 64 | $this->symbols['R_short'] = Emoji::raisedFist(); 65 | $this->symbols['P'] = 'PAPER'; 66 | $this->symbols['P_short'] = Emoji::raisedHand(); 67 | $this->symbols['S'] = 'SCISSORS'; 68 | $this->symbols['S_short'] = Emoji::victoryHand(); 69 | $this->symbols['L'] = 'LIZARD'; 70 | $this->symbols['L_short'] = Emoji::okHand(); 71 | $this->symbols['A'] = 'SPOCK'; 72 | $this->symbols['A_short'] = Emoji::vulcanSalute(); 73 | $this->symbols['valid'] = ['R', 'P', 'S', 'L', 'A']; 74 | } 75 | 76 | /** 77 | * Check whenever game is over 78 | * 79 | * @param string $x 80 | * @param string $y 81 | * 82 | * @return string 83 | */ 84 | protected function isGameOver(string $x, string $y): ?string 85 | { 86 | if ($x == 'P' && $y == 'R') { 87 | return 'X'; 88 | } 89 | 90 | if ($y == 'P' && $x == 'R') { 91 | return 'O'; 92 | } 93 | 94 | if ($x == 'P' && $y == 'A') { 95 | return 'X'; 96 | } 97 | 98 | if ($y == 'P' && $x == 'A') { 99 | return 'O'; 100 | } 101 | 102 | if ($x == 'R' && $y == 'S') { 103 | return 'X'; 104 | } 105 | 106 | if ($y == 'R' && $x == 'S') { 107 | return 'O'; 108 | } 109 | 110 | if ($x == 'R' && $y == 'L') { 111 | return 'X'; 112 | } 113 | 114 | if ($y == 'R' && $x == 'L') { 115 | return 'O'; 116 | } 117 | 118 | if ($x == 'S' && $y == 'P') { 119 | return 'X'; 120 | } 121 | 122 | if ($y == 'S' && $x == 'P') { 123 | return 'O'; 124 | } 125 | 126 | if ($x == 'S' && $y == 'L') { 127 | return 'X'; 128 | } 129 | 130 | if ($y == 'S' && $x == 'L') { 131 | return 'O'; 132 | } 133 | 134 | if ($x == 'L' && $y == 'P') { 135 | return 'X'; 136 | } 137 | 138 | if ($y == 'L' && $x == 'P') { 139 | return 'O'; 140 | } 141 | 142 | if ($x == 'L' && $y == 'A') { 143 | return 'X'; 144 | } 145 | 146 | if ($y == 'L' && $x == 'A') { 147 | return 'O'; 148 | } 149 | 150 | if ($x == 'A' && $y == 'S') { 151 | return 'X'; 152 | } 153 | 154 | if ($y == 'A' && $x == 'S') { 155 | return 'O'; 156 | } 157 | 158 | if ($x == 'A' && $y == 'R') { 159 | return 'X'; 160 | } 161 | 162 | if ($y == 'A' && $x == 'R') { 163 | return 'O'; 164 | } 165 | 166 | if ($y == $x) { 167 | return 'T'; 168 | } 169 | 170 | return null; 171 | } 172 | 173 | /** 174 | * Keyboard for game in progress 175 | * 176 | * @param bool $isOver 177 | * 178 | * @return InlineKeyboard 179 | * @throws BotException 180 | */ 181 | protected function customGameKeyboard(bool $isOver = false): InlineKeyboard 182 | { 183 | if (!$isOver) { 184 | $inline_keyboard[] = [ 185 | new InlineKeyboardButton( 186 | [ 187 | 'text' => $this->symbols['R'] . ' ' . $this->symbols['R_short'], 188 | 'callback_data' => self::getCode() . ';game;R', 189 | ] 190 | ), 191 | new InlineKeyboardButton( 192 | [ 193 | 'text' => $this->symbols['P'] . ' ' . $this->symbols['P_short'], 194 | 'callback_data' => self::getCode() . ';game;P', 195 | ] 196 | ), 197 | new InlineKeyboardButton( 198 | [ 199 | 'text' => $this->symbols['S'] . ' ' . $this->symbols['S_short'], 200 | 'callback_data' => self::getCode() . ';game;S', 201 | ] 202 | ), 203 | ]; 204 | $inline_keyboard[] = [ 205 | new InlineKeyboardButton( 206 | [ 207 | 'text' => $this->symbols['L'] . ' ' . $this->symbols['L_short'], 208 | 'callback_data' => self::getCode() . ';game;L', 209 | ] 210 | ), 211 | new InlineKeyboardButton( 212 | [ 213 | 'text' => $this->symbols['A'] . ' ' . $this->symbols['A_short'], 214 | 'callback_data' => self::getCode() . ';game;A', 215 | ] 216 | ), 217 | ]; 218 | } else { 219 | $inline_keyboard[] = [ 220 | new InlineKeyboardButton( 221 | [ 222 | 'text' => __('Play again!'), 223 | 'callback_data' => self::getCode() . ';start', 224 | ] 225 | ), 226 | ]; 227 | } 228 | 229 | if (getenv('DEBUG') && $this->getCurrentUserId() == getenv('BOT_ADMIN')) { 230 | $inline_keyboard[] = [ 231 | new InlineKeyboardButton( 232 | [ 233 | 'text' => 'DEBUG: ' . 'Restart', 234 | 'callback_data' => self::getCode() . ';start', 235 | ] 236 | ), 237 | ]; 238 | } 239 | 240 | $inline_keyboard[] = [ 241 | new InlineKeyboardButton( 242 | [ 243 | 'text' => __('Quit'), 244 | 'callback_data' => self::getCode() . ';quit', 245 | ] 246 | ), 247 | new InlineKeyboardButton( 248 | [ 249 | 'text' => __('Kick'), 250 | 'callback_data' => self::getCode() . ';kick', 251 | ] 252 | ), 253 | ]; 254 | 255 | return new InlineKeyboard(...$inline_keyboard); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/Entity/Game/Russianroulette.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | use Bot\Entity\Game; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Helper\Utilities; 17 | use Longman\TelegramBot\Entities\InlineKeyboard; 18 | use Longman\TelegramBot\Entities\InlineKeyboardButton; 19 | use Longman\TelegramBot\Entities\ServerResponse; 20 | use Longman\TelegramBot\Exception\TelegramException; 21 | use Spatie\Emoji\Emoji; 22 | 23 | /** 24 | * Russian Roulette 25 | */ 26 | class Russianroulette extends Game 27 | { 28 | /** 29 | * Game unique ID 30 | * 31 | * @var string 32 | */ 33 | protected static $code = 'rr'; 34 | 35 | /** 36 | * Game name / title 37 | * 38 | * @var string 39 | */ 40 | protected static $title = 'Russian Roulette'; 41 | 42 | /** 43 | * Game description 44 | * 45 | * @var string 46 | */ 47 | protected static $description = 'Russian roulette is a game of chance in which a player places a single round in a revolver, spins the cylinder, places the muzzle against their head, and pulls the trigger.'; 48 | 49 | /** 50 | * Game thumbnail image 51 | * 52 | * @var string 53 | */ 54 | protected static $image = 'https://i.imgur.com/LffxQLK.jpg'; 55 | 56 | /** 57 | * Order on the games list 58 | * 59 | * @var int 60 | */ 61 | protected static $order = 30; 62 | 63 | /** 64 | * Game handler 65 | * 66 | * @return ServerResponse 67 | * 68 | * @throws BotException 69 | * @throws TelegramException 70 | * @throws StorageException 71 | */ 72 | protected function gameAction(): ServerResponse 73 | { 74 | if ($this->getCurrentUserId() !== $this->getUserId('host') && $this->getCurrentUserId() !== $this->getUserId('guest')) { 75 | return $this->answerCallbackQuery(__("You're not in this game!"), true); 76 | } 77 | 78 | $data = &$this->data['game_data']; 79 | 80 | $this->defineSymbols(); 81 | 82 | $callbackquery_data = $this->manager->getUpdate()->getCallbackQuery()->getData(); 83 | $callbackquery_data = explode(';', $callbackquery_data); 84 | 85 | $command = $callbackquery_data[1]; 86 | 87 | $arg = $callbackquery_data[2] ?? null; 88 | 89 | if ($command === 'start') { 90 | if (isset($data['settings']) && $data['settings']['X'] == 'host') { 91 | $data['settings']['X'] = 'guest'; 92 | $data['settings']['O'] = 'host'; 93 | } else { 94 | $data['settings']['X'] = 'host'; 95 | $data['settings']['O'] = 'guest'; 96 | } 97 | 98 | $data['current_turn'] = 'X'; 99 | $data['cylinder'] = ['', '', '', '', '', '']; 100 | /** @noinspection RandomApiMigrationInspection */ 101 | $data['cylinder'][mt_rand(0, 5)] = 'X'; 102 | 103 | Utilities::debugPrint('Game initialization'); 104 | } elseif ($arg === null) { 105 | Utilities::debugPrint('No move data received'); 106 | } 107 | 108 | if (isset($data['current_turn']) && $data['current_turn'] == 'E') { 109 | return $this->answerCallbackQuery(__("This game has ended!"), true); 110 | } 111 | 112 | if ($this->getCurrentUserId() !== $this->getUserId($data['settings'][$data['current_turn']]) && $command !== 'start') { 113 | return $this->answerCallbackQuery(__("It's not your turn!"), true); 114 | } 115 | 116 | $hit = ''; 117 | $gameOutput = ''; 118 | 119 | if (isset($arg)) { 120 | if ($arg === 'null') { 121 | return $this->answerCallbackQuery(); 122 | } 123 | 124 | if (!isset($data['cylinder'][$arg - 1])) { 125 | Utilities::debugPrint('Bad move data received: ' . $arg); 126 | 127 | return $this->answerCallbackQuery(__("Invalid move!"), true); 128 | } 129 | 130 | Utilities::debugPrint('Chamber selected: ' . $arg); 131 | 132 | if ($data['cylinder'][$arg - 1] === 'X') { 133 | Utilities::debugPrint('Chamber contains bullet, player is dead'); 134 | 135 | if ($data['current_turn'] == 'X') { 136 | $gameOutput = Emoji::skull() . ' ' . __("{PLAYER} died! (kicked)", ['{PLAYER}' => '' . $this->getUserMention($data['settings']['X']) . '']) . '' . PHP_EOL; 137 | $gameOutput .= Emoji::trophy() . ' ' . __("{PLAYER} won!", ['{PLAYER}' => '' . $this->getUserMention($data['settings']['O']) . '']) . '' . PHP_EOL; 138 | 139 | 140 | if ($data['settings']['X'] === 'host') { 141 | $this->data['players']['host'] = $this->data['players']['guest']; 142 | $this->data['players']['guest'] = null; 143 | } else { 144 | $this->data['players']['guest'] = null; 145 | } 146 | 147 | $data['current_turn'] = 'E'; 148 | } elseif ($data['current_turn'] == 'O') { 149 | $gameOutput = Emoji::skull() . ' ' . __("{PLAYER} died! (kicked)", ['{PLAYER}' => '' . $this->getUserMention($data['settings']['O']) . '']) . '' . PHP_EOL; 150 | $gameOutput .= Emoji::trophy() . ' ' . __("{PLAYER} won!", ['{PLAYER}' => '' . $this->getUserMention($data['settings']['X']) . '']) . ''; 151 | 152 | 153 | if ($data['settings']['O'] === 'host') { 154 | $this->data['players']['host'] = $this->data['players']['guest']; 155 | $this->data['players']['guest'] = null; 156 | } else { 157 | $this->data['players']['guest'] = null; 158 | } 159 | 160 | $data['current_turn'] = 'E'; 161 | } 162 | 163 | $hit = $arg; 164 | 165 | if ($this->saveData($this->data)) { 166 | return $this->editMessage($gameOutput . PHP_EOL . PHP_EOL . __('{PLAYER_HOST} is waiting for opponent to join...', ['{PLAYER_HOST}' => $this->getUserMention('host')]) . PHP_EOL . __('Press {BUTTON} button to join.', ['{BUTTON}' => '\'' . __('Join') . '\'']), $this->customGameKeyboard($hit)); 167 | } 168 | } 169 | 170 | $gameOutput = Emoji::smilingFaceWithSunglasses() . ' ' . __("{PLAYER} survived!", ['{PLAYER}' => '' . $this->getCurrentUserMention() . '']) . '' . PHP_EOL; 171 | 172 | if ($data['current_turn'] == 'X') { 173 | $data['current_turn'] = 'O'; 174 | } elseif ($data['current_turn'] == 'O') { 175 | $data['current_turn'] = 'X'; 176 | } 177 | 178 | $data['cylinder'] = ['', '', '', '', '', '']; 179 | /** @noinspection RandomApiMigrationInspection */ 180 | $data['cylinder'][mt_rand(0, 5)] = 'X'; 181 | } 182 | 183 | $gameOutput .= Emoji::playButton() . ' ' . $this->getUserMention($data['settings'][$data['current_turn']]); 184 | 185 | Utilities::isDebugPrintEnabled() && Utilities::debugPrint('Cylinder: |' . implode('|', $data['cylinder']) . '|'); 186 | 187 | if ($this->saveData($this->data)) { 188 | return $this->editMessage( 189 | $this->getUserMention('host') . ' vs. ' . $this->getUserMention('guest') . PHP_EOL . PHP_EOL . $gameOutput, 190 | $this->customGameKeyboard($hit) 191 | ); 192 | } 193 | 194 | return parent::gameAction(); 195 | } 196 | 197 | /** 198 | * Define game symbols (emojis) 199 | */ 200 | protected function defineSymbols(): void 201 | { 202 | $this->symbols['empty'] = '.'; 203 | 204 | $this->symbols['chamber'] = Emoji::radioButton(); 205 | $this->symbols['chamber_hit'] = Emoji::redCircle(); 206 | } 207 | 208 | /** 209 | * Keyboard for game in progress 210 | * 211 | * @param string $hit 212 | * 213 | * @return InlineKeyboard 214 | * @throws BotException 215 | */ 216 | protected function customGameKeyboard(string $hit = null): InlineKeyboard 217 | { 218 | $inline_keyboard[] = [ 219 | new InlineKeyboardButton( 220 | [ 221 | 'text' => $this->symbols['empty'], 222 | 'callback_data' => self::getCode() . ';game;null', 223 | ] 224 | ), 225 | new InlineKeyboardButton( 226 | [ 227 | 'text' => ($hit == 1) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 228 | 'callback_data' => self::getCode() . ';game;1', 229 | ] 230 | ), 231 | new InlineKeyboardButton( 232 | [ 233 | 'text' => ($hit == 2) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 234 | 'callback_data' => self::getCode() . ';game;2', 235 | ] 236 | ), 237 | new InlineKeyboardButton( 238 | [ 239 | 'text' => $this->symbols['empty'], 240 | 'callback_data' => self::getCode() . ';game;null', 241 | ] 242 | ), 243 | ]; 244 | 245 | $inline_keyboard[] = [ 246 | new InlineKeyboardButton( 247 | [ 248 | 'text' => ($hit == 6) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 249 | 'callback_data' => self::getCode() . ';game;6', 250 | ] 251 | ), 252 | new InlineKeyboardButton( 253 | [ 254 | 'text' => $this->symbols['empty'], 255 | 'callback_data' => self::getCode() . ';game;null', 256 | ] 257 | ), 258 | new InlineKeyboardButton( 259 | [ 260 | 'text' => ($hit == 3) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 261 | 'callback_data' => self::getCode() . ';game;3', 262 | ] 263 | ), 264 | ]; 265 | 266 | $inline_keyboard[] = [ 267 | new InlineKeyboardButton( 268 | [ 269 | 'text' => $this->symbols['empty'], 270 | 'callback_data' => self::getCode() . ';game;null', 271 | ] 272 | ), 273 | new InlineKeyboardButton( 274 | [ 275 | 'text' => ($hit == 5) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 276 | 'callback_data' => self::getCode() . ';game;5', 277 | ] 278 | ), 279 | new InlineKeyboardButton( 280 | [ 281 | 'text' => ($hit == 4) ? $this->symbols['chamber_hit'] : $this->symbols['chamber'], 282 | 'callback_data' => self::getCode() . ';game;4', 283 | ] 284 | ), 285 | new InlineKeyboardButton( 286 | [ 287 | 'text' => $this->symbols['empty'], 288 | 'callback_data' => self::getCode() . ';game;null', 289 | ] 290 | ), 291 | ]; 292 | 293 | if (!is_numeric($hit)) { 294 | $inline_keyboard[] = [ 295 | new InlineKeyboardButton( 296 | [ 297 | 'text' => __('Quit'), 298 | 'callback_data' => self::getCode() . ';quit', 299 | ] 300 | ), 301 | new InlineKeyboardButton( 302 | [ 303 | 'text' => __('Kick'), 304 | 'callback_data' => self::getCode() . ';kick', 305 | ] 306 | ), 307 | ]; 308 | } else { 309 | $inline_keyboard[] = [ 310 | new InlineKeyboardButton( 311 | [ 312 | 'text' => __('Quit'), 313 | 'callback_data' => self::getCode() . ';quit', 314 | ] 315 | ), 316 | new InlineKeyboardButton( 317 | [ 318 | 'text' => __('Join'), 319 | 'callback_data' => self::getCode() . ';join', 320 | ] 321 | ), 322 | ]; 323 | } 324 | 325 | if (getenv('DEBUG') && $this->getCurrentUserId() == getenv('BOT_ADMIN')) { 326 | $inline_keyboard[] = [ 327 | new InlineKeyboardButton( 328 | [ 329 | 'text' => 'DEBUG: ' . 'Restart', 330 | 'callback_data' => self::getCode() . ';start', 331 | ] 332 | ), 333 | ]; 334 | } 335 | 336 | return new InlineKeyboard(...$inline_keyboard); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/Entity/Game/Tictacfour.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | /** 14 | * Tic-Tac-Four 15 | */ 16 | class Tictacfour extends Tictactoe 17 | { 18 | /** 19 | * Game unique ID 20 | * 21 | * @var string 22 | */ 23 | protected static $code = 'ttf'; 24 | 25 | /** 26 | * Game name / title 27 | * 28 | * @var string 29 | */ 30 | protected static $title = 'Tic-Tac-Four'; 31 | 32 | /** 33 | * Game description 34 | * 35 | * @var string 36 | */ 37 | protected static $description = 'Tic-tac-four is a game for two players, X and O, who take turns marking the spaces in a 7×6 grid.'; 38 | 39 | /** 40 | * Game thumbnail image 41 | * 42 | * @var string 43 | */ 44 | protected static $image = 'https://i.imgur.com/DSB90oa.png'; 45 | 46 | /** 47 | * Order on the games list 48 | * 49 | * @var int 50 | */ 51 | protected static $order = 2; 52 | 53 | /** 54 | * Base starting board 55 | * 56 | * @var array 57 | */ 58 | protected static $board = [ 59 | ['', '', '', '', '', '', ''], 60 | ['', '', '', '', '', '', ''], 61 | ['', '', '', '', '', '', ''], 62 | ['', '', '', '', '', '', ''], 63 | ['', '', '', '', '', '', ''], 64 | ['', '', '', '', '', '', ''], 65 | ]; 66 | 67 | /** 68 | * Check whenever game is over 69 | * 70 | * @param array $board 71 | * 72 | * @return string 73 | */ 74 | protected function isGameOver(array &$board): ?string 75 | { 76 | $empty = 0; 77 | for ($x = 0; $x <= $this->max_x; $x++) { 78 | for ($y = 0; $y <= $this->max_y; $y++) { 79 | if (isset($board[$x][$y]) && $board[$x][$y] == '') { 80 | $empty++; 81 | } 82 | 83 | if (isset($board[$x][$y]) && isset($board[$x][$y + 1]) && isset($board[$x][$y + 2]) && isset($board[$x][$y + 3])) { 84 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x][$y + 1] && $board[$x][$y] == $board[$x][$y + 2] && $board[$x][$y] == $board[$x][$y + 3]) { 85 | $winner = $board[$x][$y]; 86 | $board[$x][$y + 1] = $board[$x][$y] . '_won'; 87 | $board[$x][$y + 2] = $board[$x][$y] . '_won'; 88 | $board[$x][$y + 3] = $board[$x][$y] . '_won'; 89 | $board[$x][$y] = $board[$x][$y] . '_won'; 90 | 91 | return $winner; 92 | } 93 | } 94 | 95 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y]) && isset($board[$x + 2][$y]) && isset($board[$x + 3][$y])) { 96 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y] && $board[$x][$y] == $board[$x + 2][$y] && $board[$x][$y] == $board[$x + 3][$y]) { 97 | $winner = $board[$x][$y]; 98 | $board[$x + 1][$y] = $board[$x][$y] . '_won'; 99 | $board[$x + 2][$y] = $board[$x][$y] . '_won'; 100 | $board[$x + 3][$y] = $board[$x][$y] . '_won'; 101 | $board[$x][$y] = $board[$x][$y] . '_won'; 102 | 103 | return $winner; 104 | } 105 | } 106 | 107 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y + 1]) && isset($board[$x + 2][$y + 2]) && isset($board[$x + 3][$y + 3])) { 108 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y + 1] && $board[$x][$y] == $board[$x + 2][$y + 2] && $board[$x][$y] == $board[$x + 3][$y + 3]) { 109 | $winner = $board[$x][$y]; 110 | $board[$x + 1][$y + 1] = $board[$x][$y] . '_won'; 111 | $board[$x + 2][$y + 2] = $board[$x][$y] . '_won'; 112 | $board[$x + 3][$y + 3] = $board[$x][$y] . '_won'; 113 | $board[$x][$y] = $board[$x][$y] . '_won'; 114 | 115 | return $winner; 116 | } 117 | } 118 | 119 | if (isset($board[$x][$y]) && isset($board[$x - 1][$y + 1]) && isset($board[$x - 2][$y + 2]) && isset($board[$x - 3][$y + 3])) { 120 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x - 1][$y + 1] && $board[$x][$y] == $board[$x - 2][$y + 2] && $board[$x][$y] == $board[$x - 3][$y + 3]) { 121 | $winner = $board[$x][$y]; 122 | $board[$x - 1][$y + 1] = $board[$x][$y] . '_won'; 123 | $board[$x - 2][$y + 2] = $board[$x][$y] . '_won'; 124 | $board[$x - 3][$y + 3] = $board[$x][$y] . '_won'; 125 | $board[$x][$y] = $board[$x][$y] . '_won'; 126 | 127 | return $winner; 128 | } 129 | } 130 | } 131 | } 132 | 133 | if ($empty == 0) { 134 | return 'T'; 135 | } 136 | 137 | return null; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Entity/Game/Tictactoe.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity\Game; 12 | 13 | use Bot\Entity\Game; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Helper\Utilities; 17 | use Longman\TelegramBot\Entities\ServerResponse; 18 | use Longman\TelegramBot\Exception\TelegramException; 19 | use Spatie\Emoji\Emoji; 20 | 21 | /** 22 | * Tic-Tac-Toe 23 | */ 24 | class Tictactoe extends Game 25 | { 26 | /** 27 | * Game unique ID 28 | * 29 | * @var string 30 | */ 31 | protected static $code = 'xo'; 32 | 33 | /** 34 | * Game name / title 35 | * 36 | * @var string 37 | */ 38 | protected static $title = 'Tic-Tac-Toe'; 39 | 40 | /** 41 | * Game description 42 | * 43 | * @var string 44 | */ 45 | protected static $description = 'Tic-tac-toe is a game for two players, X and O, who take turns marking the spaces in a 3×3 grid.'; 46 | 47 | /** 48 | * Game thumbnail image 49 | * 50 | * @var string 51 | */ 52 | protected static $image = 'http://i.imgur.com/yU2uexr.png'; 53 | 54 | /** 55 | * Order on the games list 56 | * 57 | * @var int 58 | */ 59 | protected static $order = 1; 60 | 61 | /** 62 | * Base starting board 63 | * 64 | * @var array 65 | */ 66 | protected static $board = [ 67 | ['', '', ''], 68 | ['', '', ''], 69 | ['', '', ''], 70 | ]; 71 | 72 | /** 73 | * Game handler 74 | * 75 | * @return ServerResponse 76 | * 77 | * @throws BotException 78 | * @throws TelegramException 79 | * @throws StorageException 80 | */ 81 | protected function gameAction(): ServerResponse 82 | { 83 | if ($this->getCurrentUserId() !== $this->getUserId('host') && $this->getCurrentUserId() !== $this->getUserId('guest')) { 84 | return $this->answerCallbackQuery(__("You're not in this game!"), true); 85 | } 86 | 87 | $data = &$this->data['game_data']; 88 | 89 | $this->defineSymbols(); 90 | 91 | $callbackquery_data = $this->manager->getUpdate()->getCallbackQuery()->getData(); 92 | $callbackquery_data = explode(';', $callbackquery_data); 93 | 94 | $command = $callbackquery_data[1]; 95 | 96 | $args = null; 97 | if (isset($callbackquery_data[2])) { 98 | $args = explode('-', $callbackquery_data[2]); 99 | } 100 | 101 | if ($command === 'start') { 102 | if (isset($data['settings']) && $data['settings']['X'] == 'host') { 103 | $data['settings']['X'] = 'guest'; 104 | $data['settings']['O'] = 'host'; 105 | } else { 106 | $data['settings']['X'] = 'host'; 107 | $data['settings']['O'] = 'guest'; 108 | } 109 | 110 | $data['current_turn'] = 'X'; 111 | $data['board'] = static::$board; 112 | 113 | Utilities::debugPrint('Game initialization'); 114 | } elseif ($args === null) { 115 | Utilities::debugPrint('No move data received'); 116 | } 117 | 118 | if (isset($data['current_turn']) && $data['current_turn'] == 'E') { 119 | return $this->answerCallbackQuery(__("This game has ended!"), true); 120 | } 121 | 122 | if ($this->getCurrentUserId() !== $this->getUserId($data['settings'][$data['current_turn']]) && $command !== 'start') { 123 | return $this->answerCallbackQuery(__("It's not your turn!"), true); 124 | } 125 | 126 | if (isset($args) && isset($data['board'][$args[0]][$args[1]]) && $data['board'][$args[0]][$args[1]] !== '') { 127 | return $this->answerCallbackQuery(__("Invalid move!"), true); 128 | } 129 | 130 | $this->max_y = count($data['board']); 131 | $this->max_x = count($data['board'][0]); 132 | 133 | if (isset($args)) { 134 | if ($data['current_turn'] == 'X') { 135 | $data['board'][$args[0]][$args[1]] = 'X'; 136 | $data['current_turn'] = 'O'; 137 | } elseif ($data['current_turn'] == 'O') { 138 | $data['board'][$args[0]][$args[1]] = 'O'; 139 | $data['current_turn'] = 'X'; 140 | } else { 141 | Utilities::debugPrint('Invalid move data: ' . ($args[0]) . ' - ' . ($args[1])); 142 | 143 | return $this->answerCallbackQuery(__("Invalid move!"), true); 144 | } 145 | 146 | Utilities::debugPrint($data['current_turn'] . ' placed at ' . ($args[1]) . ' - ' . ($args[0])); 147 | } 148 | 149 | $isOver = $this->isGameOver($data['board']); 150 | $gameOutput = ''; 151 | 152 | if (!empty($isOver) && in_array($isOver, ['X', 'O'])) { 153 | $gameOutput = Emoji::trophy() . ' ' . __("{PLAYER} won!", ['{PLAYER}' => '' . $this->getUserMention($data['settings'][$isOver]) . '']) . ''; 154 | } elseif ($isOver == 'T') { 155 | $gameOutput = Emoji::chequeredFlag() . ' ' . __("Game ended with a draw!") . ''; 156 | } 157 | 158 | if (!empty($isOver) && in_array($isOver, ['X', 'O', 'T'])) { 159 | $data['current_turn'] = 'E'; 160 | } else { 161 | $gameOutput = Emoji::playButton() . ' ' . $this->getUserMention($data['settings'][$data['current_turn']]) . ' (' . $this->symbols[$data['current_turn']] . ')'; 162 | } 163 | 164 | if ($this->saveData($this->data)) { 165 | return $this->editMessage( 166 | $this->getUserMention('host') . ' (' . (($data['settings']['X'] == 'host') ? $this->symbols['X'] : $this->symbols['O']) . ')' . ' vs. ' . $this->getUserMention('guest') . ' (' . (($data['settings']['O'] == 'guest') ? $this->symbols['O'] : $this->symbols['X']) . ')' . PHP_EOL . PHP_EOL . $gameOutput, 167 | $this->gameKeyboard($data['board'], $isOver) 168 | ); 169 | } 170 | 171 | return parent::gameAction(); 172 | } 173 | 174 | /** 175 | * Define game symbols (emojis) 176 | */ 177 | protected function defineSymbols(): void 178 | { 179 | $this->symbols['empty'] = '.'; 180 | 181 | $this->symbols['X'] = Emoji::crossMark(); 182 | $this->symbols['O'] = Emoji::hollowRedCircle(); 183 | 184 | $this->symbols['X_won'] = $this->symbols['X']; 185 | $this->symbols['O_won'] = $this->symbols['O']; 186 | 187 | $this->symbols['X_lost'] = Emoji::multiply(); 188 | $this->symbols['O_lost'] = Emoji::radioButton(); 189 | } 190 | 191 | /** 192 | * Check whenever game is over 193 | * 194 | * @param array $board 195 | * 196 | * @return string 197 | */ 198 | protected function isGameOver(array &$board): ?string 199 | { 200 | $empty = 0; 201 | for ($x = 0; $x < $this->max_x; $x++) { 202 | for ($y = 0; $y < $this->max_y; $y++) { 203 | if ($board[$x][$y] == '') { 204 | $empty++; 205 | } 206 | 207 | if (isset($board[$x][$y]) && isset($board[$x][$y + 1]) && isset($board[$x][$y + 2])) { 208 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x][$y + 1] && $board[$x][$y] == $board[$x][$y + 2]) { 209 | $winner = $board[$x][$y]; 210 | $board[$x][$y + 1] = $board[$x][$y] . '_won'; 211 | $board[$x][$y + 2] = $board[$x][$y] . '_won'; 212 | $board[$x][$y] = $board[$x][$y] . '_won'; 213 | 214 | return $winner; 215 | } 216 | } 217 | 218 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y]) && isset($board[$x + 2][$y])) { 219 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y] && $board[$x][$y] == $board[$x + 2][$y]) { 220 | $winner = $board[$x][$y]; 221 | 222 | $board[$x + 1][$y] = $board[$x][$y] . '_won'; 223 | $board[$x + 2][$y] = $board[$x][$y] . '_won'; 224 | $board[$x][$y] = $board[$x][$y] . '_won'; 225 | 226 | return $winner; 227 | } 228 | } 229 | 230 | if (isset($board[$x][$y]) && isset($board[$x + 1][$y + 1]) && isset($board[$x + 2][$y + 2])) { 231 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x + 1][$y + 1] && $board[$x][$y] == $board[$x + 2][$y + 2]) { 232 | $winner = $board[$x][$y]; 233 | 234 | $board[$x + 1][$y + 1] = $board[$x][$y] . '_won'; 235 | $board[$x + 2][$y + 2] = $board[$x][$y] . '_won'; 236 | $board[$x][$y] = $board[$x][$y] . '_won'; 237 | 238 | return $winner; 239 | } 240 | } 241 | 242 | if (isset($board[$x][$y]) && isset($board[$x - 1][$y + 1]) && isset($board[$x - 2][$y + 2])) { 243 | if ($board[$x][$y] != '' && $board[$x][$y] == $board[$x - 1][$y + 1] && $board[$x][$y] == $board[$x - 2][$y + 2]) { 244 | $winner = $board[$x][$y]; 245 | $board[$x - 1][$y + 1] = $board[$x][$y] . '_won'; 246 | $board[$x - 2][$y + 2] = $board[$x][$y] . '_won'; 247 | $board[$x][$y] = $board[$x][$y] . '_won'; 248 | 249 | return $winner; 250 | } 251 | } 252 | } 253 | } 254 | 255 | if ($empty == 0) { 256 | return 'T'; 257 | } 258 | 259 | return null; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/Entity/TempFile.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Entity; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Helper\Utilities; 15 | use RuntimeException; 16 | use SplFileInfo; 17 | 18 | /** 19 | * A temporary file handler, with removal after it's not used 20 | */ 21 | class TempFile 22 | { 23 | /** 24 | * The temporary file, or false 25 | * 26 | * @var null|SplFileInfo 27 | */ 28 | private $file; 29 | 30 | /** 31 | * Should the file be delete after script ends 32 | * 33 | * @var bool 34 | */ 35 | private $delete; 36 | 37 | /** 38 | * TempFile constructor 39 | * 40 | * @param string $name 41 | * @param bool $delete 42 | * 43 | * @throws BotException 44 | */ 45 | public function __construct($name, $delete = true) 46 | { 47 | $this->delete = $delete; 48 | 49 | if (defined('DATA_PATH')) { 50 | $this->file = DATA_PATH . '/tmp/' . $name . '.tmp'; 51 | } else { 52 | $this->file = sys_get_temp_dir() . '/' . md5(__DIR__) . '/' . $name . '.tmp'; 53 | } 54 | 55 | if (!is_dir(dirname($this->file)) && !mkdir($concurrentDirectory = dirname($this->file), 0755, true) && !is_dir($concurrentDirectory)) { 56 | throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); 57 | } 58 | 59 | if (!is_writable(dirname($this->file))) { 60 | throw new BotException('Destination path is not writable: ' . dirname($this->file)); 61 | } 62 | 63 | touch($this->file); 64 | $this->file = new SplFileInfo($this->file); 65 | 66 | Utilities::isDebugPrintEnabled() && Utilities::debugPrint('File: ' . realpath($this->file)); 67 | } 68 | 69 | /** 70 | * Delete the file when script ends (unless specified to not) 71 | */ 72 | public function __destruct() 73 | { 74 | if ($this->delete && $this->file !== null && file_exists($this->file)) { 75 | @unlink($this->file); 76 | } 77 | } 78 | 79 | /** 80 | * Get the file path or false 81 | * 82 | * @return null|SplFileInfo 83 | */ 84 | public function getFile(): ?SplFileInfo 85 | { 86 | return $this->file; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Exception/BotException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Exception; 12 | 13 | use Exception; 14 | 15 | /** 16 | * Exception class used for bot logic related exception handling 17 | */ 18 | class BotException extends Exception 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/StorageException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Exception; 12 | 13 | use Bot\Storage\Storage; 14 | use Exception; 15 | use Throwable; 16 | 17 | /** 18 | * Exception class used for bot storage related exception handling 19 | */ 20 | class StorageException extends BotException 21 | { 22 | /** 23 | * @param string $message 24 | * @param int $code 25 | * @param Throwable|null $previous 26 | */ 27 | public function __construct($message = "", $code = 0, Throwable $previous = null) 28 | { 29 | try { 30 | parent::__construct($message . ' [' . Storage::getClass() . ']', $code, $previous); 31 | } catch (Exception $e) { 32 | parent::__construct($message, $code, $previous); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exception/TelegramApiException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Exception; 12 | 13 | use Longman\TelegramBot\Entities\ServerResponse; 14 | 15 | /** 16 | * Exception class used for Telegram API related exception handling 17 | */ 18 | class TelegramApiException extends BotException 19 | { 20 | /** 21 | * @param string $message 22 | * @param int $code 23 | * @param ServerResponse|null $result 24 | */ 25 | public function __construct($message = "", $code = 0, ServerResponse $result = null) 26 | { 27 | parent::__construct($message, $code, $result); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/GameCore.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot; 12 | 13 | use Bot\Entity\Game; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use Bot\Exception\TelegramApiException; 17 | use Bot\Helper\Language; 18 | use Bot\Helper\Utilities; 19 | use Bot\Storage\Driver\File; 20 | use Bot\Storage\Storage; 21 | use DirectoryIterator; 22 | use Longman\TelegramBot\Commands\Command; 23 | use Longman\TelegramBot\Entities\ServerResponse; 24 | use Longman\TelegramBot\Entities\Update; 25 | use Longman\TelegramBot\Exception\TelegramException; 26 | use Longman\TelegramBot\Request; 27 | use Throwable; 28 | 29 | /** 30 | * This is the 'manager', it does everything what's required before running a game 31 | */ 32 | class GameCore 33 | { 34 | /** 35 | * Game session ID (inline_message_id) 36 | * 37 | * @var string 38 | */ 39 | private $id; 40 | 41 | /** 42 | * Current game object 43 | * 44 | * @var Game 45 | */ 46 | private $game; 47 | 48 | /** 49 | * Currently used storage class name 50 | * 51 | * @var string|File 52 | */ 53 | private $storage; 54 | 55 | /** 56 | * Telegram Update object 57 | * 58 | * @var Update 59 | */ 60 | private $update; 61 | 62 | /** 63 | * GameManager constructor 64 | * 65 | * @param string $id 66 | * @param string $game_code 67 | * @param Command $command 68 | * 69 | * @throws BotException 70 | * @throws StorageException 71 | * @throws TelegramException 72 | */ 73 | public function __construct(string $id, string $game_code, Command $command) 74 | { 75 | if (empty($id)) { 76 | throw new BotException('Id is empty!'); 77 | } 78 | 79 | if (empty($game_code)) { 80 | throw new BotException('Game code is empty!'); 81 | } 82 | 83 | Utilities::debugPrint('ID: ' . $id); 84 | 85 | $this->id = $id; 86 | $this->update = $command->getUpdate(); 87 | 88 | try { 89 | /** @var File $storage_class */ 90 | $this->storage = $storage_class = Storage::getClass(); 91 | $storage_class::initializeStorage(); 92 | } catch (StorageException $e) { 93 | $this->notifyAboutStorageFailure(); 94 | 95 | if (strpos($e->getMessage(), 'too many connections') !== false) { 96 | return; 97 | } 98 | 99 | throw $e; 100 | } 101 | 102 | if ($game = $this->findGame($game_code)) { 103 | /** @var Game $game_class */ 104 | $this->game = $game_class = $game; 105 | 106 | Utilities::debugPrint('Game: ' . $game_class::getTitle()); 107 | } else { 108 | Utilities::debugPrint('Game not found'); 109 | } 110 | } 111 | 112 | /** 113 | * Show information to user that storage is not accessible 114 | * 115 | * @return ServerResponse 116 | * 117 | * @throws TelegramException 118 | */ 119 | protected function notifyAboutStorageFailure(): ServerResponse 120 | { 121 | Utilities::debugPrint('Database error'); 122 | 123 | if ($callback_query = $this->update->getCallbackQuery()) { 124 | return Request::answerCallbackQuery( 125 | [ 126 | 'callback_query_id' => $callback_query->getId(), 127 | 'text' => __('Database error!') . PHP_EOL . PHP_EOL . __("Try again in a few seconds."), 128 | 'show_alert' => true, 129 | ] 130 | ); 131 | } 132 | 133 | return Request::emptyResponse(); 134 | } 135 | 136 | /** 137 | * Find game class based on game code 138 | * 139 | * @param string $game_code 140 | * 141 | * @return Game|bool 142 | */ 143 | private function findGame(string $game_code) 144 | { 145 | if (is_dir(SRC_PATH . '/Entity/Game')) { 146 | foreach (new DirectoryIterator(SRC_PATH . '/Entity/Game') as $file) { 147 | if (!$file->isDir() && !$file->isDot() && $file->getExtension() === 'php') { 148 | $game_class = '\Bot\Entity\Game\\' . basename($file->getFilename(), '.php'); 149 | 150 | /** @var Game $game_class */ 151 | if ($game_class::getCode() == $game_code) { 152 | return new $game_class($this); 153 | } 154 | } 155 | } 156 | } 157 | 158 | return false; 159 | } 160 | 161 | /** 162 | * Returns whenever the game can run (game is valid and loaded) 163 | * 164 | * @return bool 165 | */ 166 | public function canRun(): bool 167 | { 168 | if ($this->game instanceof Game) { 169 | return true; 170 | } 171 | 172 | return false; 173 | } 174 | 175 | /** 176 | * Run the game class 177 | * 178 | * @return ServerResponse 179 | * 180 | * @throws TelegramApiException 181 | * @throws BotException 182 | * @throws StorageException 183 | * @throws TelegramException 184 | * @throws Throwable 185 | */ 186 | public function run(): ServerResponse 187 | { 188 | $callback_query = $this->getUpdate()->getCallbackQuery(); 189 | $chosen_inline_result = $this->getUpdate()->getChosenInlineResult(); 190 | $storage_class = $this->storage; 191 | 192 | if (!$storage_class::lockGame($this->id)) { 193 | return $this->notifyAboutStorageLock(); 194 | } 195 | 196 | Utilities::debugPrint('BEGIN HANDLING THE GAME'); 197 | 198 | try { 199 | if ($callback_query) { 200 | $result = $this->game->handleAction(explode(';', $callback_query->getData())[1]); 201 | $storage_class::unlockGame($this->id); 202 | } elseif ($chosen_inline_result) { 203 | $result = $this->game->handleAction('new'); 204 | $storage_class::unlockGame($this->id); 205 | } else { 206 | throw new BotException('Unknown update received!'); 207 | } 208 | } catch (TelegramApiException | TelegramException $e) { 209 | $this->notifyAboutTelegramApiFailure(); 210 | 211 | if (strpos($e->getMessage(), 'Telegram returned an invalid response') !== false) { 212 | return new ServerResponse(['ok' => false, 'description' => 'Telegram returned an invalid response'], ''); 213 | } 214 | 215 | throw $e; 216 | } catch (StorageException $e) { 217 | $this->notifyAboutStorageFailure(); 218 | throw $e; 219 | } catch (BotException $e) { 220 | $this->notifyAboutBotFailure(); 221 | throw $e; 222 | } catch (Throwable $e) { 223 | $this->notifyAboutUnknownFailure(); 224 | throw $e; 225 | } 226 | 227 | Utilities::debugPrint('GAME HANDLED'); 228 | 229 | return $result; 230 | } 231 | 232 | /** 233 | * Return Update object 234 | * 235 | * @return Update 236 | */ 237 | public function getUpdate(): Update 238 | { 239 | return $this->update; 240 | } 241 | 242 | /** 243 | * Returns notice about storage lock 244 | * 245 | * @return ServerResponse 246 | * 247 | * @throws TelegramException 248 | */ 249 | protected function notifyAboutStorageLock(): ServerResponse 250 | { 251 | Utilities::debugPrint('Storage is locked'); 252 | 253 | if ($callback_query = $this->update->getCallbackQuery()) { 254 | return Request::answerCallbackQuery( 255 | [ 256 | 'callback_query_id' => $callback_query->getId(), 257 | 'text' => __('Process for this game is busy!') . PHP_EOL . PHP_EOL . __("Try again in a few seconds."), 258 | 'show_alert' => true, 259 | ] 260 | ); 261 | } 262 | 263 | return Request::emptyResponse(); 264 | } 265 | 266 | /** 267 | * Returns notice about bot failure 268 | * 269 | * @return ServerResponse 270 | * 271 | * @throws TelegramException 272 | */ 273 | protected function notifyAboutTelegramApiFailure(): ServerResponse 274 | { 275 | Utilities::debugPrint('Telegram API error'); 276 | 277 | if ($callback_query = $this->update->getCallbackQuery()) { 278 | return Request::answerCallbackQuery( 279 | [ 280 | 'callback_query_id' => $callback_query->getId(), 281 | 'text' => __('Telegram API error!') . PHP_EOL . PHP_EOL . __("Try again in a few seconds."), 282 | 'show_alert' => true, 283 | ] 284 | ); 285 | } 286 | 287 | return Request::emptyResponse(); 288 | } 289 | 290 | /** 291 | * Returns notice about bot failure 292 | * 293 | * @return ServerResponse 294 | * 295 | * @throws TelegramException 296 | */ 297 | protected function notifyAboutBotFailure(): ServerResponse 298 | { 299 | Utilities::debugPrint('Bot error'); 300 | 301 | if ($callback_query = $this->update->getCallbackQuery()) { 302 | return Request::answerCallbackQuery( 303 | [ 304 | 'callback_query_id' => $callback_query->getId(), 305 | 'text' => __('Bot error!') . PHP_EOL . PHP_EOL . __("Try again in a few seconds."), 306 | 'show_alert' => true, 307 | ] 308 | ); 309 | } 310 | 311 | return Request::emptyResponse(); 312 | } 313 | 314 | /** 315 | * Returns notice about unknown failure 316 | * 317 | * @return ServerResponse 318 | * 319 | * @throws TelegramException 320 | */ 321 | protected function notifyAboutUnknownFailure(): ServerResponse 322 | { 323 | Utilities::debugPrint('Unknown error'); 324 | 325 | if ($callback_query = $this->update->getCallbackQuery()) { 326 | return Request::answerCallbackQuery( 327 | [ 328 | 'callback_query_id' => $callback_query->getId(), 329 | 'text' => __('Unhandled error!') . PHP_EOL . PHP_EOL . __("Try again in a few seconds."), 330 | 'show_alert' => true, 331 | ] 332 | ); 333 | } 334 | 335 | return Request::emptyResponse(); 336 | } 337 | 338 | /** 339 | * Get game id 340 | * 341 | * @return string 342 | */ 343 | public function getId(): string 344 | { 345 | return $this->id; 346 | } 347 | 348 | /** 349 | * Get game object 350 | * 351 | * @return Game|mixed 352 | */ 353 | public function getGame(): Game 354 | { 355 | return $this->game; 356 | } 357 | 358 | /** 359 | * Get storage class 360 | * 361 | * @return string 362 | */ 363 | public function getStorage(): string 364 | { 365 | return $this->storage; 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/Helper/Language.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Helper; 12 | 13 | use DirectoryIterator; 14 | use Gettext\Translations; 15 | use Gettext\Translator; 16 | use RuntimeException; 17 | 18 | /** 19 | * Simple localization class 20 | */ 21 | class Language 22 | { 23 | /** 24 | * Default strings language 25 | * 26 | * @var string 27 | */ 28 | private static $default_language = 'en'; 29 | 30 | /** 31 | * Current language 32 | * 33 | * @var string 34 | */ 35 | private static $current_language = ''; 36 | 37 | /** 38 | * Return language list 39 | * 40 | * @return array 41 | */ 42 | public static function list(): array 43 | { 44 | $languages = [self::$default_language]; 45 | 46 | if (is_dir(ROOT_PATH . '/language')) { 47 | foreach (new DirectoryIterator(ROOT_PATH . '/language') as $fileInfo) { 48 | if (!$fileInfo->isDir() && !$fileInfo->isDot()) { 49 | $language = explode('.', $fileInfo->getFilename()); 50 | 51 | if ($language[1] != self::getDefaultlanguage() && isset($language[2]) && $language[2] == 'po') { 52 | $languages[] = $language[1]; 53 | } 54 | } 55 | } 56 | } 57 | 58 | return $languages; 59 | } 60 | 61 | /** 62 | * Get default language 63 | * 64 | * @return string 65 | */ 66 | public static function getDefaultLanguage(): string 67 | { 68 | if (!empty($default_language = getenv('DEFAULT_LANGUAGE'))) { 69 | return $default_language; 70 | } 71 | 72 | return self::$default_language; 73 | } 74 | 75 | /** 76 | * Set default language 77 | * 78 | * @param string $default_language 79 | */ 80 | public static function setDefaultLanguage(string $default_language): void 81 | { 82 | self::$default_language = $default_language; 83 | self::set($default_language); 84 | } 85 | 86 | /** 87 | * Set the language and load translation 88 | * 89 | * @param string $language 90 | */ 91 | public static function set(string $language = ''): void 92 | { 93 | $t = new Translator(); 94 | 95 | if (file_exists(ROOT_PATH . '/translations/messages.' . $language . '.po')) { 96 | if (defined('DATA_PATH')) { 97 | if (!file_exists(ROOT_PATH . '/translations/messages.' . $language . '.cache') || md5_file(ROOT_PATH . '/translations/messages.' . $language . '.po') != file_get_contents(DATA_PATH . '/translations/messages.' . $language . '.cache')) { 98 | if (!self::compileToArray($language)) { 99 | Utilities::debugPrint('Language compilation to PHP array failed!'); 100 | } 101 | } 102 | 103 | if (file_exists(DATA_PATH . '/translations/messages.' . $language . '.php')) { 104 | $t->loadTranslations(DATA_PATH . '/translations/messages.' . $language . '.php'); 105 | } 106 | } else { 107 | $translations = Translations::fromPoFile(ROOT_PATH . '/translations/messages.' . $language . '.po'); 108 | $t->loadTranslations($translations); 109 | } 110 | 111 | self::$current_language = $language; 112 | } else { 113 | self::$current_language = self::$default_language; 114 | } 115 | 116 | $t->register(); 117 | } 118 | 119 | /** 120 | * Compile .po file into .php array file 121 | * 122 | * @param string $language 123 | * 124 | * @return bool 125 | */ 126 | private static function compileToArray(string $language): bool 127 | { 128 | if (defined('DATA_PATH') && !file_exists(ROOT_PATH . '/translations/messages.' . $language . '.php')) { 129 | $translation = Translations::fromPoFile(ROOT_PATH . '/translations/messages.' . $language . '.po'); 130 | 131 | if (!is_dir(DATA_PATH . '/translations/') && !mkdir($concurrentDirectory = DATA_PATH . '/translations/', 0755, true) && !is_dir($concurrentDirectory)) { 132 | throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); 133 | } 134 | 135 | $translation->toPhpArrayFile(DATA_PATH . '/translations/messages.' . $language . '.php'); 136 | 137 | return file_put_contents(DATA_PATH . '/translations/messages.' . $language . '.cache', md5_file(ROOT_PATH . '/translations/messages.' . $language . '.po')); 138 | } 139 | 140 | return false; 141 | } 142 | 143 | /** 144 | * Get current language 145 | * 146 | * @return string 147 | */ 148 | public static function getCurrentLanguage(): string 149 | { 150 | return self::$current_language ?: self::$default_language; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Helper/Utilities.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Helper; 12 | 13 | use Longman\TelegramBot\Entities\Update; 14 | use Longman\TelegramBot\TelegramLog; 15 | use Psr\Log\LoggerInterface; 16 | 17 | /** 18 | * Extra functions 19 | */ 20 | class Utilities 21 | { 22 | /** 23 | * Is debug print enabled? 24 | * 25 | * @var bool 26 | */ 27 | private static $debug_print_enabled = false; 28 | 29 | /** 30 | * Logger instance 31 | * 32 | * @var LoggerInterface 33 | */ 34 | private static $debug_print_logger = null; 35 | 36 | /** 37 | * Show debug message and (if enabled) write to debug log 38 | * 39 | * @param string $text 40 | * @param array $context 41 | */ 42 | public static function debugPrint(string $text, array $context = []): void 43 | { 44 | if (PHP_SAPI === 'cli' && self::$debug_print_enabled) { 45 | if ($text === '') { 46 | return; 47 | } 48 | 49 | $prefix = ''; 50 | $backtrace = debug_backtrace(); 51 | 52 | if (isset($backtrace[1]['class'])) { 53 | $prefix = $backtrace[1]['class'] . '\\' . $backtrace[1]['function']; 54 | } 55 | 56 | TelegramLog::debug('[' . $prefix . '] ' . $text . ' ' . json_encode($context)); 57 | 58 | if (self::$debug_print_logger !== null) { 59 | if (strpos($text, PHP_EOL) !== false) { 60 | $text = explode(PHP_EOL, trim($text)); 61 | 62 | foreach ($text as $line) { 63 | self::$debug_print_logger->debug('[' . $prefix . '] ' . trim($line), $context ?? []); 64 | } 65 | } else { 66 | self::$debug_print_logger->debug('[' . $prefix . '] ' . trim($text), $context ?? []); 67 | } 68 | } else { 69 | $prefix = '[' . date('Y-m-d H:i:s') . '] ' . $prefix . ': '; 70 | $message = $prefix . trim($text); 71 | 72 | if (!empty($context)) { 73 | $message .= ' ' . \json_encode($context); 74 | } 75 | 76 | $message = preg_replace('~[\r\n]+~', PHP_EOL . $prefix, $message); 77 | 78 | print $message . PHP_EOL; 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Enable/disable debug print 85 | * 86 | * @param bool $enabled 87 | */ 88 | public static function setDebugPrint(bool $enabled = true): void 89 | { 90 | self::$debug_print_enabled = $enabled; 91 | } 92 | 93 | /** 94 | * Inject logger into the debug print function 95 | * 96 | * @param LoggerInterface $logger 97 | */ 98 | public static function setDebugPrintLogger(LoggerInterface $logger): void 99 | { 100 | self::$debug_print_logger = $logger; 101 | } 102 | 103 | /** 104 | * Check if debug print is enabled 105 | */ 106 | public static function isDebugPrintEnabled(): bool 107 | { 108 | if (PHP_SAPI === 'cli') { 109 | return self::$debug_print_enabled; 110 | } 111 | 112 | return false; 113 | } 114 | 115 | /** 116 | * Make a debug dump from array of debug data 117 | * 118 | * @param string $message 119 | * @param array $data 120 | * 121 | * @return string 122 | */ 123 | public static function debugDump(string $message = '', array $data = []): string 124 | { 125 | if (!empty($message)) { 126 | $output = $message . PHP_EOL; 127 | } else { 128 | $output = PHP_EOL; 129 | } 130 | 131 | foreach ($data as $var => $val) { 132 | /** @noinspection NestedTernaryOperatorInspection */ 133 | $output .= $var . ': ' . (is_array($val) ? print_r($val, true) : (is_bool($val) ? ($val ? 'true' : 'false') : $val)) . PHP_EOL; 134 | } 135 | 136 | return $output; 137 | } 138 | 139 | /** 140 | * strpos() with array needle 141 | * https://stackoverflow.com/a/9220624 142 | * 143 | * @param string $haystack 144 | * @param array $needle 145 | * @param int $offset 146 | * 147 | * @return bool|mixed 148 | */ 149 | public static function strposa(string $haystack, array $needle, int $offset = 0) 150 | { 151 | if (!is_array($needle)) { 152 | $needle = [$needle]; 153 | } 154 | foreach ($needle as $query) { 155 | if (strpos($haystack, $query, $offset) !== false) { 156 | return true; 157 | } 158 | } 159 | 160 | return false; 161 | } 162 | 163 | /** 164 | * Convert update object to array then remove 'raw_data' and 'bot_username' from it 165 | * 166 | * @param Update $update 167 | * 168 | * @return array 169 | */ 170 | public static function updateToArray(Update $update): array 171 | { 172 | $update_array = json_decode(json_encode($update), true); 173 | unset($update_array['raw_data']); 174 | unset($update_array['bot_username']); 175 | 176 | return $update_array; 177 | } 178 | 179 | /** 180 | * Formats bytes to human readable format 181 | * https://stackoverflow.com/a/2510459 182 | * 183 | * @param int $bytes 184 | * @param int $precision 185 | * 186 | * @return string 187 | */ 188 | public static function formatBytes(int $bytes, int $precision = 2): string 189 | { 190 | $units = ['B', 'KB', 'MB', 'GB', 'TB']; 191 | 192 | $bytes = max($bytes, 0); 193 | $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); 194 | $pow = min($pow, count($units) - 1); 195 | $bytes /= 1024 ** $pow; 196 | 197 | return round($bytes, $precision) . ' ' . $units[$pow]; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Storage/Driver/BotDB.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage\Driver; 12 | 13 | use Bot\Exception\BotException; 14 | use Bot\Exception\StorageException; 15 | use Longman\TelegramBot\DB; 16 | 17 | /** 18 | * Pulls PDO connection from \Longman\TelegramBot\DB and redirects all calls to \Bot\Storage\MySQL 19 | */ 20 | class BotDB extends DB 21 | { 22 | /** 23 | * Create table structure 24 | * 25 | * @return bool 26 | * 27 | * @throws StorageException 28 | */ 29 | public static function createStructure(): bool 30 | { 31 | self::initializeStorage(); 32 | 33 | return MySQL::createStructure(); 34 | } 35 | 36 | /** 37 | * Initialize PDO connection 38 | * 39 | * @return bool 40 | * 41 | * @throws StorageException 42 | */ 43 | public static function initializeStorage(): bool 44 | { 45 | return MySQL::initializeStorage(self::$pdo); 46 | } 47 | 48 | /** 49 | * Select data from database 50 | * 51 | * @param string $id 52 | * 53 | * @return array|bool 54 | * 55 | * @throws StorageException 56 | */ 57 | public static function selectFromGame(string $id) 58 | { 59 | return MySQL::selectFromGame($id); 60 | } 61 | 62 | /** 63 | * Insert data to database 64 | * 65 | * @param string $id 66 | * @param array $data 67 | * 68 | * @return bool 69 | * 70 | * @throws StorageException 71 | */ 72 | public static function insertToGame(string $id, array $data): bool 73 | { 74 | return MySQL::insertToGame($id, $data); 75 | } 76 | 77 | /** 78 | * Delete data from storage 79 | * 80 | * @param string $id 81 | * 82 | * @return bool 83 | * 84 | * @throws StorageException 85 | */ 86 | public static function deleteFromGame(string $id): bool 87 | { 88 | return MySQL::deleteFromGame($id); 89 | } 90 | 91 | /** 92 | * Lock the row to prevent another process modifying it 93 | * 94 | * @param string $id 95 | * 96 | * @return bool 97 | * 98 | * @throws BotException 99 | * @throws StorageException 100 | */ 101 | public static function lockGame(string $id): bool 102 | { 103 | return MySQL::lockGame($id); 104 | } 105 | 106 | /** 107 | * Unlock the row after 108 | * 109 | * @param string $id 110 | * 111 | * @return bool 112 | * 113 | * @throws StorageException 114 | */ 115 | public static function unlockGame(string $id): bool 116 | { 117 | return MySQL::unlockGame($id); 118 | } 119 | 120 | /** 121 | * Select multiple data from the database 122 | * 123 | * @param int $time 124 | * 125 | * @return array|bool 126 | * 127 | * @throws StorageException 128 | */ 129 | public static function listFromGame(int $time = 0) 130 | { 131 | return MySQL::listFromGame($time); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Storage/Driver/File.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage\Driver; 12 | 13 | use Bot\Entity\TempFile; 14 | use Bot\Exception\StorageException; 15 | use DirectoryIterator; 16 | use RuntimeException; 17 | 18 | /** 19 | * Stores data in json formatted text files 20 | */ 21 | class File 22 | { 23 | /** 24 | * Lock file object 25 | * 26 | * @var TempFile 27 | */ 28 | private static $lock; 29 | 30 | /** 31 | * Initialize - define paths 32 | * 33 | * @throws StorageException 34 | */ 35 | public static function initializeStorage(): bool 36 | { 37 | if (!defined('STORAGE_GAME_PATH')) { 38 | if (!defined('DATA_PATH')) { 39 | throw new StorageException('Data path is not set!'); 40 | } 41 | 42 | define('STORAGE_GAME_PATH', DATA_PATH . '/game'); 43 | 44 | if (!is_dir(STORAGE_GAME_PATH) && !mkdir($concurrentDirectory = STORAGE_GAME_PATH, 0755, true) && !is_dir($concurrentDirectory)) { 45 | throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory)); 46 | } 47 | } 48 | 49 | return true; 50 | } 51 | 52 | /** 53 | * Dummy function 54 | * 55 | * @return bool 56 | */ 57 | public static function createStructure(): bool 58 | { 59 | return true; 60 | } 61 | 62 | /** 63 | * Read data from the file 64 | * 65 | * @param string $id 66 | * 67 | * @return array|bool 68 | * @throws StorageException 69 | */ 70 | public static function selectFromGame(string $id) 71 | { 72 | if (empty($id)) { 73 | throw new StorageException('Id is empty!'); 74 | } 75 | 76 | if (file_exists(STORAGE_GAME_PATH . '/' . $id . '.json')) { 77 | return json_decode(file_get_contents(STORAGE_GAME_PATH . '/' . $id . '.json'), true) ?? []; 78 | } 79 | 80 | return []; 81 | } 82 | 83 | /** 84 | * Place data to the file 85 | * 86 | * @param string $id 87 | * @param array $data 88 | * 89 | * @return bool 90 | * @throws StorageException 91 | */ 92 | public static function insertToGame(string $id, array $data): bool 93 | { 94 | if (empty($id)) { 95 | throw new StorageException('Id is empty!'); 96 | } 97 | 98 | $data['updated_at'] = time(); 99 | 100 | if (!isset($data['created_at'])) { 101 | $data['created_at'] = $data['updated_at']; 102 | } 103 | 104 | if (file_exists(STORAGE_GAME_PATH . '/' . $id . '.json')) { 105 | return file_put_contents(STORAGE_GAME_PATH . '/' . $id . '.json', json_encode($data)); 106 | } 107 | 108 | return false; 109 | } 110 | 111 | /** 112 | * Remove data file 113 | * 114 | * @param string $id 115 | * 116 | * @return bool 117 | * @throws StorageException 118 | */ 119 | public static function deleteFromGame(string $id): bool 120 | { 121 | if (empty($id)) { 122 | throw new StorageException('Id is empty!'); 123 | } 124 | 125 | if (file_exists(STORAGE_GAME_PATH . '/' . $id . '.json')) { 126 | return unlink(STORAGE_GAME_PATH . '/' . $id . '.json'); 127 | } 128 | 129 | return false; 130 | } 131 | 132 | /** 133 | * Lock the file to prevent another process modifying it 134 | * 135 | * @param string $id 136 | * 137 | * @return bool 138 | * @throws StorageException 139 | */ 140 | public static function lockGame(string $id): bool 141 | { 142 | if (empty($id)) { 143 | throw new StorageException('Id is empty!'); 144 | } 145 | 146 | self::$lock = new TempFile($id); 147 | if (self::$lock->getFile() === null) { 148 | return false; 149 | } 150 | 151 | if (flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_EX)) { 152 | if (!file_exists(STORAGE_GAME_PATH . '/' . $id . '.json')) { 153 | file_put_contents(STORAGE_GAME_PATH . '/' . $id . '.json', json_encode([])); 154 | } 155 | 156 | return true; 157 | } 158 | 159 | return false; 160 | } 161 | 162 | /** 163 | * Unlock the file after 164 | * 165 | * @param string $id 166 | * 167 | * @return bool 168 | * @throws StorageException 169 | */ 170 | public static function unlockGame(string $id): bool 171 | { 172 | if (empty($id)) { 173 | throw new StorageException('Id is empty!'); 174 | } 175 | 176 | if (self::$lock === null) { 177 | throw new StorageException('No lock file object!'); 178 | } 179 | 180 | if (self::$lock->getFile() === null) { 181 | return false; 182 | } 183 | 184 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_UN); 185 | } 186 | 187 | /** 188 | * Select inactive data fields from database 189 | * 190 | * @param int $time 191 | * 192 | * @return array 193 | * @throws StorageException 194 | */ 195 | public static function listFromGame(int $time = 0): array 196 | { 197 | if (!is_numeric($time)) { 198 | throw new StorageException('Time must be a number!'); 199 | } 200 | 201 | $ids = []; 202 | foreach (new DirectoryIterator(STORAGE_GAME_PATH) as $file) { 203 | if (!$file->isDir() && !$file->isDot() && $file->getMTime() + $time < time()) { 204 | $data = file_get_contents($file->getPathname()); 205 | $json = json_decode($data, true); 206 | $data_stripped = json_encode(['game_code' => $json['game_code'] ?? null]); 207 | 208 | $ids[] = ['id' => trim(basename($file->getFilename(), '.json')), 'data' => $data_stripped, 'updated_at' => date('H:i:s d-m-Y', filemtime($file->getPathname()))]; 209 | } 210 | } 211 | 212 | return $ids; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Storage/Driver/Memcache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage\Driver; 12 | 13 | use Bot\Entity\TempFile; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use MemCachier\MemcacheSASL; 17 | use Memcache as MemcacheCore; 18 | use Memcached as MemcachedCore; 19 | 20 | /** 21 | * Class Memcache 22 | */ 23 | class Memcache 24 | { 25 | /** 26 | * Memcache object 27 | * 28 | * @var MemcachedCore|MemcacheCore 29 | */ 30 | private static $memcache; 31 | 32 | /** 33 | * Lock file object 34 | * 35 | * @var TempFile 36 | */ 37 | private static $lock; 38 | 39 | /** 40 | * Create table structure 41 | * 42 | * @return bool 43 | * @throws StorageException 44 | */ 45 | public static function createStructure(): bool 46 | { 47 | return true; 48 | } 49 | 50 | /** 51 | * Check if database connection has been created 52 | * 53 | * @return bool 54 | */ 55 | public static function isDbConnected(): bool 56 | { 57 | return self::$memcache !== null; 58 | } 59 | 60 | /** 61 | * Initialize connection 62 | * 63 | * @return bool 64 | * 65 | * @throws StorageException 66 | */ 67 | public static function initializeStorage(): bool 68 | { 69 | if (self::isDbConnected()) { 70 | return true; 71 | } 72 | 73 | try { 74 | $dsn = parse_url(getenv('DATABASE_URL')); 75 | 76 | if (class_exists(MemcacheSASL::class) && stripos($dsn['host'], 'memcachier') !== false) { 77 | $memcache = new MemcacheSASL(); 78 | 79 | $memcache->setOption(MemcacheSASL::OPT_COMPRESSION, true); 80 | $memcache->setOption(MemcacheSASL::OPT_CONNECT_TIMEOUT, 1000); 81 | $memcache->setOption(MemcacheSASL::OPT_RECV_TIMEOUT, 10000); 82 | $memcache->setOption(MemcacheSASL::OPT_SEND_TIMEOUT, 10000); 83 | } elseif (class_exists(MemcachedCore::class)) { 84 | $memcache = new MemcachedCore($persistent_id ?? null); 85 | 86 | $memcache->setOption(MemcachedCore::OPT_BINARY_PROTOCOL, true); 87 | $memcache->setOption(MemcachedCore::OPT_NO_BLOCK, true); 88 | $memcache->setOption(MemcachedCore::OPT_COMPRESSION, true); 89 | $memcache->setOption(MemcachedCore::OPT_CONNECT_TIMEOUT, 1000); 90 | $memcache->setOption(MemcachedCore::OPT_RECV_TIMEOUT, 10000); 91 | $memcache->setOption(MemcachedCore::OPT_SEND_TIMEOUT, 10000); 92 | } elseif (class_exists(MemcacheCore::class)) { 93 | $memcache = new MemcacheCore($persistent_id ?? null); 94 | } else { 95 | throw new StorageException('Unsupported database type!'); 96 | } 97 | 98 | $memcache->addServer($dsn['host'], $dsn['port']); 99 | 100 | if (isset($dsn['user'], $dsn['pass'])) { 101 | if (!method_exists($memcache, 'setSaslAuthData')) { 102 | throw new \RuntimeException('Memcached extension was not build with SASL support'); 103 | } 104 | 105 | $memcache->setSaslAuthData($dsn['user'], $dsn['pass']); 106 | } 107 | 108 | self::$memcache = $memcache; 109 | } catch (\Exception $e) { 110 | throw new StorageException('Connection to the memcached server failed: ' . $e->getMessage()); 111 | } 112 | 113 | return true; 114 | } 115 | 116 | /** 117 | * Select data from database 118 | * 119 | * @param string $id 120 | * 121 | * @return array|bool 122 | * @throws StorageException 123 | */ 124 | public static function selectFromGame(string $id) 125 | { 126 | if (!self::isDbConnected()) { 127 | return false; 128 | } 129 | 130 | if (empty($id)) { 131 | throw new StorageException('Id is empty!'); 132 | } 133 | 134 | $data = self::$memcache->get('game_' . sha1($id)); 135 | 136 | if ($data !== null) { 137 | return json_decode($data, true) ?? []; 138 | } 139 | 140 | return []; 141 | } 142 | 143 | /** 144 | * Insert data to database 145 | * 146 | * @param string $id 147 | * @param array $data 148 | * 149 | * @return bool 150 | * @throws StorageException 151 | */ 152 | public static function insertToGame(string $id, array $data): bool 153 | { 154 | if (!self::isDbConnected()) { 155 | return false; 156 | } 157 | 158 | if (empty($id)) { 159 | throw new StorageException('Id is empty!'); 160 | } 161 | 162 | if (empty($data)) { 163 | throw new StorageException('Data is empty!'); 164 | } 165 | 166 | return self::$memcache->set('game_' . sha1($id), json_encode($data)); 167 | } 168 | 169 | /** 170 | * Delete data from storage 171 | * 172 | * @param string $id 173 | * 174 | * @return bool 175 | * @throws StorageException 176 | */ 177 | public static function deleteFromGame(string $id): bool 178 | { 179 | if (!self::isDbConnected()) { 180 | return false; 181 | } 182 | 183 | if (empty($id)) { 184 | throw new StorageException('Id is empty!'); 185 | } 186 | 187 | return self::$memcache->delete('game_' . sha1($id)); 188 | } 189 | 190 | /** 191 | * Basic file-powered lock to prevent other process accessing same game 192 | * 193 | * @param string $id 194 | * 195 | * @return bool 196 | * 197 | * @throws StorageException 198 | * @throws BotException 199 | */ 200 | public static function lockGame(string $id): bool 201 | { 202 | if (!self::isDbConnected()) { 203 | return false; 204 | } 205 | 206 | if (empty($id)) { 207 | throw new StorageException('Id is empty!'); 208 | } 209 | 210 | self::$lock = new TempFile($id); 211 | if (self::$lock->getFile() === null) { 212 | return false; 213 | } 214 | 215 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_EX); 216 | } 217 | 218 | /** 219 | * Unlock the game to allow access from other processes 220 | * 221 | * @param string $id 222 | * 223 | * @return bool 224 | * 225 | * @throws StorageException 226 | */ 227 | public static function unlockGame(string $id): bool 228 | { 229 | if (!self::isDbConnected()) { 230 | return false; 231 | } 232 | 233 | if (empty($id)) { 234 | throw new StorageException('Id is empty!'); 235 | } 236 | 237 | if (self::$lock === null) { 238 | throw new StorageException('No lock file object!'); 239 | } 240 | 241 | if (self::$lock->getFile() === null) { 242 | return false; 243 | } 244 | 245 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_UN); 246 | } 247 | 248 | /** 249 | * Select multiple data from the database 250 | * 251 | * @param int $time 252 | * 253 | * @return array|bool 254 | * @throws StorageException 255 | */ 256 | public static function listFromGame(int $time = 0) 257 | { 258 | if (!self::isDbConnected()) { 259 | return false; 260 | } 261 | 262 | return []; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/Storage/Driver/MySQL.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage\Driver; 12 | 13 | use Bot\Entity\TempFile; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use PDO; 17 | use PDOException; 18 | 19 | /** 20 | * Class MySQL 21 | */ 22 | class MySQL 23 | { 24 | /** 25 | * PDO object 26 | * 27 | * @var PDO 28 | */ 29 | private static $pdo; 30 | 31 | /** 32 | * Lock file object 33 | * 34 | * @var TempFile 35 | */ 36 | private static $lock; 37 | 38 | /** 39 | * SQL to create database structure 40 | * 41 | * @var string 42 | */ 43 | private static $structure = 'CREATE TABLE IF NOT EXISTS `game` ( 44 | `id` CHAR(255) COMMENT "Unique identifier for this entry", 45 | `data` TEXT NOT NULL COMMENT "Stored data", 46 | `created_at` timestamp NULL DEFAULT NULL COMMENT "Entry creation date", 47 | `updated_at` timestamp NULL DEFAULT NULL COMMENT "Entry update date", 48 | 49 | PRIMARY KEY (`id`) 50 | );'; 51 | 52 | /** 53 | * Create table structure 54 | * 55 | * @return bool 56 | * @throws StorageException 57 | */ 58 | public static function createStructure(): bool 59 | { 60 | if (!self::isDbConnected()) { 61 | self::initializeStorage(); 62 | } 63 | 64 | if (!self::$pdo->query(self::$structure)) { 65 | throw new StorageException('Failed to create DB structure!'); 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * Check if database connection has been created 73 | * 74 | * @return bool 75 | */ 76 | public static function isDbConnected(): bool 77 | { 78 | return self::$pdo !== null; 79 | } 80 | 81 | /** 82 | * Initialize PDO connection 83 | * 84 | * @param $pdo 85 | * 86 | * @return bool 87 | * 88 | * @throws StorageException 89 | */ 90 | public static function initializeStorage($pdo = null): bool 91 | { 92 | if (self::isDbConnected()) { 93 | return true; 94 | } 95 | 96 | if (!defined('TB_GAME')) { 97 | define('TB_GAME', 'game'); 98 | } 99 | 100 | if ($pdo === null) { 101 | try { 102 | $dsn = parse_url(getenv('DATABASE_URL')); 103 | 104 | self::$pdo = new PDO('mysql:' . 'host=' . $dsn['host'] . ';port=' . $dsn['port'] . ';dbname=' . ltrim($dsn['path'], '/'), $dsn['user'], $dsn['pass']); 105 | self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING); 106 | } catch (PDOException $e) { 107 | throw new StorageException('Connection to the database failed: ' . $e->getMessage()); 108 | } 109 | } else { 110 | self::$pdo = $pdo; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | /** 117 | * Select data from database 118 | * 119 | * @param string $id 120 | * 121 | * @return array|bool 122 | * @throws StorageException 123 | */ 124 | public static function selectFromGame(string $id) 125 | { 126 | if (!self::isDbConnected()) { 127 | return false; 128 | } 129 | 130 | if (empty($id)) { 131 | throw new StorageException('Id is empty!'); 132 | } 133 | 134 | try { 135 | $sth = self::$pdo->prepare( 136 | ' 137 | SELECT * FROM `' . TB_GAME . '` 138 | WHERE `id` = :id 139 | ' 140 | ); 141 | 142 | $sth->bindParam(':id', $id); 143 | 144 | if ($result = $sth->execute()) { 145 | $result = $sth->fetchAll(PDO::FETCH_ASSOC); 146 | 147 | return isset($result[0]) ? json_decode($result[0]['data'], true) : []; 148 | } 149 | 150 | return false; 151 | } catch (PDOException $e) { 152 | throw new StorageException($e->getMessage()); 153 | } 154 | } 155 | 156 | /** 157 | * Insert data to database 158 | * 159 | * @param string $id 160 | * @param array $data 161 | * 162 | * @return bool 163 | * @throws StorageException 164 | */ 165 | public static function insertToGame(string $id, array $data): bool 166 | { 167 | if (!self::isDbConnected()) { 168 | return false; 169 | } 170 | 171 | if (empty($id)) { 172 | throw new StorageException('Id is empty!'); 173 | } 174 | 175 | if (empty($data)) { 176 | throw new StorageException('Data is empty!'); 177 | } 178 | 179 | try { 180 | $sth = self::$pdo->prepare( 181 | ' 182 | INSERT INTO `' . TB_GAME . '` 183 | (`id`, `data`, `created_at`, `updated_at`) 184 | VALUES 185 | (:id, :data, :date, :date) 186 | ON DUPLICATE KEY UPDATE 187 | `data` = VALUES(`data`), 188 | `updated_at` = VALUES(`updated_at`) 189 | ' 190 | ); 191 | 192 | /** @noinspection CallableParameterUseCaseInTypeContextInspection */ 193 | $data = json_encode($data); 194 | $date = date('Y-m-d H:i:s'); 195 | 196 | $sth->bindParam(':id', $id); 197 | $sth->bindParam(':data', $data); 198 | $sth->bindParam(':date', $date); 199 | 200 | return $sth->execute(); 201 | } catch (PDOException $e) { 202 | throw new StorageException($e->getMessage()); 203 | } 204 | } 205 | 206 | /** 207 | * Delete data from storage 208 | * 209 | * @param string $id 210 | * 211 | * @return bool 212 | * @throws StorageException 213 | */ 214 | public static function deleteFromGame(string $id): bool 215 | { 216 | if (!self::isDbConnected()) { 217 | return false; 218 | } 219 | 220 | if (empty($id)) { 221 | throw new StorageException('Id is empty!'); 222 | } 223 | 224 | try { 225 | $sth = self::$pdo->prepare( 226 | ' 227 | DELETE FROM `' . TB_GAME . '` 228 | WHERE `id` = :id 229 | ' 230 | ); 231 | 232 | $sth->bindParam(':id', $id); 233 | 234 | return $sth->execute(); 235 | } catch (PDOException $e) { 236 | throw new StorageException($e->getMessage()); 237 | } 238 | } 239 | 240 | /** 241 | * Basic file-powered lock to prevent other process accessing same game 242 | * 243 | * @param string $id 244 | * 245 | * @return bool 246 | * 247 | * @throws StorageException 248 | * @throws BotException 249 | */ 250 | public static function lockGame(string $id): bool 251 | { 252 | if (!self::isDbConnected()) { 253 | return false; 254 | } 255 | 256 | if (empty($id)) { 257 | throw new StorageException('Id is empty!'); 258 | } 259 | 260 | self::$lock = new TempFile($id); 261 | if (self::$lock->getFile() === null) { 262 | return false; 263 | } 264 | 265 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_EX); 266 | } 267 | 268 | /** 269 | * Unlock the game to allow access from other processes 270 | * 271 | * @param string $id 272 | * 273 | * @return bool 274 | * 275 | * @throws StorageException 276 | */ 277 | public static function unlockGame(string $id): bool 278 | { 279 | if (!self::isDbConnected()) { 280 | return false; 281 | } 282 | 283 | if (empty($id)) { 284 | throw new StorageException('Id is empty!'); 285 | } 286 | 287 | if (self::$lock === null) { 288 | throw new StorageException('No lock file object!'); 289 | } 290 | 291 | if (self::$lock->getFile() === null) { 292 | return false; 293 | } 294 | 295 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_UN); 296 | } 297 | 298 | /** 299 | * Select multiple data from the database 300 | * 301 | * @param int $time 302 | * 303 | * @return array|bool 304 | * @throws StorageException 305 | */ 306 | public static function listFromGame(int $time = 0) 307 | { 308 | if (!self::isDbConnected()) { 309 | return false; 310 | } 311 | 312 | if (!is_numeric($time)) { 313 | throw new StorageException('Time must be a number!'); 314 | } 315 | 316 | if ($time >= 0) { 317 | $compare_sign = '<='; 318 | } else { 319 | $compare_sign = '>'; 320 | } 321 | 322 | try { 323 | $sth = self::$pdo->prepare( 324 | ' 325 | SELECT * FROM `' . TB_GAME . '` 326 | WHERE `updated_at` ' . $compare_sign . ' :date 327 | ORDER BY `updated_at` ASC 328 | ' 329 | ); 330 | 331 | $date = date('Y-m-d H:i:s', strtotime('-' . abs($time) . ' seconds')); 332 | $sth->bindParam(':date', $date); 333 | 334 | if ($sth->execute()) { 335 | //return $sth->fetchAll(PDO::FETCH_ASSOC); 336 | 337 | $results = []; 338 | while ($entry = $sth->fetch(PDO::FETCH_ASSOC)) { 339 | $json = json_decode($entry['data'], true); 340 | $data_stripped = json_encode(['game_code' => $json['game_code'] ?? null]); 341 | 342 | $results[] = [ 343 | 'id' => $entry['id'], 344 | 'data' => $data_stripped, 345 | 'updated_at' => $entry['updated_at'], 346 | ]; 347 | } 348 | 349 | return $results; 350 | } 351 | 352 | return false; 353 | } catch (PDOException $e) { 354 | throw new StorageException($e->getMessage()); 355 | } 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/Storage/Driver/PostgreSQL.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage\Driver; 12 | 13 | use Bot\Entity\TempFile; 14 | use Bot\Exception\BotException; 15 | use Bot\Exception\StorageException; 16 | use PDO; 17 | use PDOException; 18 | 19 | /** 20 | * Class PostgreSQL 21 | */ 22 | class PostgreSQL 23 | { 24 | /** 25 | * PDO object 26 | * 27 | * @var PDO 28 | */ 29 | private static $pdo; 30 | 31 | /** 32 | * Lock file object 33 | * 34 | * @var TempFile 35 | */ 36 | private static $lock; 37 | 38 | /** 39 | * SQL to create database structure 40 | * 41 | * @var string 42 | */ 43 | private static $structure = 'CREATE TABLE IF NOT EXISTS game ( 44 | id CHAR(255), 45 | data TEXT NOT NULL, 46 | created_at timestamp NULL DEFAULT NULL, 47 | updated_at timestamp NULL DEFAULT NULL, 48 | 49 | PRIMARY KEY (id) 50 | );'; 51 | 52 | /** 53 | * Create table structure 54 | * 55 | * @return bool 56 | * @throws StorageException 57 | */ 58 | public static function createStructure(): bool 59 | { 60 | if (!self::isDbConnected()) { 61 | self::initializeStorage(); 62 | } 63 | 64 | if (!self::$pdo->query(self::$structure)) { 65 | throw new StorageException('Failed to create DB structure!'); 66 | } 67 | 68 | return true; 69 | } 70 | 71 | /** 72 | * Check if database connection has been created 73 | * 74 | * @return bool 75 | */ 76 | public static function isDbConnected(): bool 77 | { 78 | return self::$pdo !== null; 79 | } 80 | 81 | /** 82 | * Initialize PDO connection 83 | * 84 | * @throws StorageException 85 | */ 86 | public static function initializeStorage(): bool 87 | { 88 | if (self::isDbConnected()) { 89 | return true; 90 | } 91 | 92 | if (!defined('TB_GAME')) { 93 | define('TB_GAME', 'game'); 94 | } 95 | 96 | try { 97 | $dsn = parse_url(getenv('DATABASE_URL')); 98 | 99 | self::$pdo = new PDO('pgsql:' . 'host=' . $dsn['host'] . ';port=' . $dsn['port'] . ';dbname=' . ltrim($dsn['path'], '/'), $dsn['user'], $dsn['pass']); 100 | self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING); 101 | } catch (PDOException $e) { 102 | throw new StorageException('Connection to the database failed: ' . $e->getMessage()); 103 | } 104 | 105 | return true; 106 | } 107 | 108 | /** 109 | * Select data from database 110 | * 111 | * @param string $id 112 | * 113 | * @return array|bool 114 | * @throws StorageException 115 | */ 116 | public static function selectFromGame(string $id) 117 | { 118 | if (!self::isDbConnected()) { 119 | return false; 120 | } 121 | 122 | if (empty($id)) { 123 | throw new StorageException('Id is empty!'); 124 | } 125 | 126 | try { 127 | $sth = self::$pdo->prepare( 128 | ' 129 | SELECT * FROM ' . TB_GAME . ' 130 | WHERE id = :id 131 | ' 132 | ); 133 | 134 | $sth->bindParam(':id', $id); 135 | 136 | if ($result = $sth->execute()) { 137 | $result = $sth->fetchAll(PDO::FETCH_ASSOC); 138 | 139 | return isset($result[0]) ? json_decode($result[0]['data'], true) : []; 140 | } 141 | 142 | return false; 143 | } catch (PDOException $e) { 144 | throw new StorageException($e->getMessage()); 145 | } 146 | } 147 | 148 | /** 149 | * Insert data to database 150 | * 151 | * @param string $id 152 | * @param array $data 153 | * 154 | * @return bool 155 | * @throws StorageException 156 | */ 157 | public static function insertToGame(string $id, array $data): bool 158 | { 159 | if (!self::isDbConnected()) { 160 | return false; 161 | } 162 | 163 | if (empty($id)) { 164 | throw new StorageException('Id is empty!'); 165 | } 166 | 167 | if (empty($data)) { 168 | throw new StorageException('Data is empty!'); 169 | } 170 | 171 | try { 172 | $sth = self::$pdo->prepare( 173 | ' 174 | INSERT INTO ' . TB_GAME . ' 175 | (id, data, created_at, updated_at) 176 | VALUES 177 | (:id, :data, :date, :date) 178 | ON CONFLICT (id) DO UPDATE 179 | SET data = :data, 180 | updated_at = :date 181 | ' 182 | ); 183 | 184 | /** @noinspection CallableParameterUseCaseInTypeContextInspection */ 185 | $data = json_encode($data); 186 | $date = date('Y-m-d H:i:s'); 187 | 188 | $sth->bindParam(':id', $id); 189 | $sth->bindParam(':data', $data); 190 | $sth->bindParam(':date', $date); 191 | 192 | return $sth->execute(); 193 | } catch (PDOException $e) { 194 | throw new StorageException($e->getMessage()); 195 | } 196 | } 197 | 198 | /** 199 | * Delete data from storage 200 | * 201 | * @param string $id 202 | * 203 | * @return array|bool|mixed 204 | * @throws StorageException 205 | */ 206 | public static function deleteFromGame(string $id): bool 207 | { 208 | if (!self::isDbConnected()) { 209 | return false; 210 | } 211 | 212 | if (empty($id)) { 213 | throw new StorageException('Id is empty!'); 214 | } 215 | 216 | try { 217 | $sth = self::$pdo->prepare( 218 | ' 219 | DELETE FROM ' . TB_GAME . ' 220 | WHERE id = :id 221 | ' 222 | ); 223 | 224 | $sth->bindParam(':id', $id); 225 | 226 | return $sth->execute(); 227 | } catch (PDOException $e) { 228 | throw new StorageException($e->getMessage()); 229 | } 230 | } 231 | 232 | /** 233 | * Basic file-powered lock to prevent other process accessing same game 234 | * 235 | * @param string $id 236 | * 237 | * @return bool 238 | * 239 | * @throws StorageException 240 | * @throws BotException 241 | */ 242 | public static function lockGame(string $id): bool 243 | { 244 | if (!self::isDbConnected()) { 245 | return false; 246 | } 247 | 248 | if (empty($id)) { 249 | throw new StorageException('Id is empty!'); 250 | } 251 | 252 | self::$lock = new TempFile($id); 253 | if (self::$lock->getFile() === null) { 254 | return false; 255 | } 256 | 257 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_EX); 258 | } 259 | 260 | /** 261 | * Unlock the game to allow access from other processes 262 | * 263 | * @param string $id 264 | * 265 | * @return bool 266 | * 267 | * @throws StorageException 268 | */ 269 | public static function unlockGame(string $id): bool 270 | { 271 | if (!self::isDbConnected()) { 272 | return false; 273 | } 274 | 275 | if (empty($id)) { 276 | throw new StorageException('Id is empty!'); 277 | } 278 | 279 | if (self::$lock === null) { 280 | throw new StorageException('No lock file object!'); 281 | } 282 | 283 | if (self::$lock->getFile() === null) { 284 | return false; 285 | } 286 | 287 | return flock(fopen(self::$lock->getFile()->getPathname(), 'ab+'), LOCK_UN); 288 | } 289 | 290 | /** 291 | * Select multiple data from the database 292 | * 293 | * @param int $time 294 | * 295 | * @return array|bool 296 | * @throws StorageException 297 | */ 298 | public static function listFromGame(int $time = 0) 299 | { 300 | if (!self::isDbConnected()) { 301 | return false; 302 | } 303 | 304 | if (!is_numeric($time)) { 305 | throw new StorageException('Time must be a number!'); 306 | } 307 | 308 | if ($time >= 0) { 309 | $compare_sign = '<='; 310 | } else { 311 | $compare_sign = '>'; 312 | } 313 | 314 | try { 315 | $sth = self::$pdo->prepare( 316 | ' 317 | SELECT * FROM ' . TB_GAME . ' 318 | WHERE updated_at ' . $compare_sign . ' :date 319 | ORDER BY updated_at ASC 320 | ' 321 | ); 322 | 323 | $date = date('Y-m-d H:i:s', strtotime('-' . abs($time) . ' seconds')); 324 | $sth->bindParam(':date', $date); 325 | 326 | if ($sth->execute()) { 327 | //return $sth->fetchAll(PDO::FETCH_ASSOC); 328 | 329 | $results = []; 330 | while ($entry = $sth->fetch(PDO::FETCH_ASSOC)) { 331 | $json = json_decode($entry['data'], true); 332 | $data_stripped = json_encode(['game_code' => $json['game_code'] ?? null]); 333 | 334 | $results[] = [ 335 | 'id' => $entry['id'], 336 | 'data' => $data_stripped, 337 | 'updated_at' => $entry['updated_at'], 338 | ]; 339 | } 340 | 341 | return $results; 342 | } 343 | 344 | return false; 345 | } catch (PDOException $e) { 346 | throw new StorageException($e->getMessage()); 347 | } 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/Storage/Storage.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot\Storage; 12 | 13 | use Bot\Exception\StorageException; 14 | use Bot\Helper\Utilities; 15 | use Bot\Storage\Driver\BotDB; 16 | use Bot\Storage\Driver\File; 17 | use Longman\TelegramBot\DB; 18 | 19 | /** 20 | * Picks the best storage driver available 21 | */ 22 | class Storage 23 | { 24 | /** 25 | * Supported database engines 26 | */ 27 | private static $storage_drivers = [ 28 | 'mysql' => 'MySQL', 29 | 'pgsql' => 'PostgreSQL', 30 | 'postgres' => 'PostgreSQL', 31 | 'memcache' => 'Memcache', 32 | 'memcached' => 'Memcache', 33 | ]; 34 | 35 | /** 36 | * Return which driver class to use 37 | * 38 | * @return string 39 | * 40 | * @throws StorageException 41 | */ 42 | public static function getClass(): string 43 | { 44 | if (!empty($debug_storage = getenv('STORAGE_CLASS'))) { 45 | $storage = str_replace('"', '', $debug_storage); 46 | Utilities::debugPrint('Forcing storage: \'' . $storage . '\''); 47 | } elseif (DB::isDbConnected()) { 48 | $storage = BotDB::class; 49 | } elseif (getenv('DATABASE_URL')) { 50 | $dsn = parse_url(getenv('DATABASE_URL')); 51 | 52 | if (!isset(self::$storage_drivers[$dsn['scheme']])) { 53 | throw new StorageException('Unsupported database type!'); 54 | } 55 | 56 | $storage = 'Bot\Storage\Driver\\' . (self::$storage_drivers[$dsn['scheme']] ?: ''); 57 | } elseif (defined('DATA_PATH')) { 58 | $storage = File::class; 59 | } 60 | 61 | if (empty($storage)) { 62 | /** @noinspection PhpUndefinedVariableInspection */ 63 | throw new StorageException('Storage class not provided'); 64 | } 65 | 66 | if (!class_exists($storage)) { 67 | /** @noinspection PhpUndefinedVariableInspection */ 68 | throw new StorageException('Storage class doesn\'t exist: ' . $storage); 69 | } 70 | 71 | Utilities::debugPrint('Using storage: \'' . $storage . '\''); 72 | 73 | return $storage; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/TelegramBot.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | namespace Bot; 12 | 13 | use Bot\Helper\Language; 14 | use Longman\TelegramBot\Entities\ServerResponse; 15 | use Longman\TelegramBot\Entities\Update; 16 | use Longman\TelegramBot\Telegram; 17 | 18 | class TelegramBot extends Telegram 19 | { 20 | /** 21 | * @param Update $update 22 | * 23 | * @return ServerResponse 24 | */ 25 | public function processUpdate(Update $update): ServerResponse 26 | { 27 | Language::set(Language::getDefaultLanguage()); // Set default language before handling each update 28 | 29 | return parent::processUpdate($update); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This should be started through Procfile on Fly.io 3 | 4 | echo "Starting worker process in the background..." 5 | ./worker.sh & 6 | 7 | echo "Starting web process..." 8 | vendor/bin/heroku-php-nginx -C nginx.inc.conf public/ 9 | -------------------------------------------------------------------------------- /translations/messages.pot: -------------------------------------------------------------------------------- 1 | #, fuzzy 2 | msgid "" 3 | msgstr "" 4 | "Project-Id-Version: \n" 5 | "Report-Msgid-Bugs-To: \n" 6 | "Last-Translator: @jacklul\n" 7 | "Language-Team: \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "POT-Creation-Date: 2021-09-16 16:00+0200\n" 12 | "PO-Revision-Date: 2017-07-13T17:47:54+00:00\n" 13 | "X-Poedit-Basepath: ../src\n" 14 | "X-Poedit-KeywordsList: __\n" 15 | "X-Generator: Poedit 2.2.4\n" 16 | "X-Poedit-SearchPath-0: .\n" 17 | 18 | #: Command/Admin/CleansessionsCommand.php:149 19 | msgid "Create New Session" 20 | msgstr "" 21 | 22 | #: Command/Admin/CleansessionsCommand.php:156 23 | msgid "This game session has expired." 24 | msgstr "" 25 | 26 | #: Command/Admin/CleansessionsCommand.php:213 27 | #: Command/System/InlinequeryCommand.php:124 Entity/Game.php:805 28 | msgid "Create" 29 | msgstr "" 30 | 31 | #: Command/System/CallbackqueryCommand.php:73 32 | msgid "Bad request!" 33 | msgstr "" 34 | 35 | #: Command/System/InlinequeryCommand.php:49 Entity/Game.php:634 36 | #: Entity/Game.php:708 37 | msgid "This game session is empty." 38 | msgstr "" 39 | 40 | #: Command/User/StartCommand.php:60 41 | msgid "Hi!" 42 | msgstr "" 43 | 44 | #: Command/User/StartCommand.php:61 Command/User/StartCommand.php:72 45 | #: Entity/Game.php:558 Entity/Game.php:787 Entity/Game.php:865 46 | msgid "Play" 47 | msgstr "" 48 | 49 | #: Command/User/StartCommand.php:61 50 | msgid "To begin, start a message with {USAGE} in any of your chats or click the {BUTTON} button and then select a chat to play in." 51 | msgstr "" 52 | 53 | #: Entity/Game.php:232 54 | msgid "This game session has crashed." 55 | msgstr "" 56 | 57 | #: Entity/Game.php:464 58 | msgid "This game is already created!" 59 | msgstr "" 60 | 61 | #: Entity/Game.php:477 Entity/Game.php:545 Entity/Game.php:623 62 | #: Entity/Game.php:648 Entity/Game.php:685 Entity/Game.php:790 63 | #: Entity/Game/Russianroulette.php:166 64 | msgid "{PLAYER_HOST} is waiting for opponent to join..." 65 | msgstr "" 66 | 67 | #: Entity/Game.php:477 Entity/Game.php:545 Entity/Game.php:623 68 | #: Entity/Game.php:648 Entity/Game.php:685 Entity/Game.php:790 69 | #: Entity/Game.php:843 Entity/Game/Russianroulette.php:166 70 | #: Entity/Game/Russianroulette.php:318 71 | msgid "Join" 72 | msgstr "" 73 | 74 | #: Entity/Game.php:477 Entity/Game.php:545 Entity/Game.php:623 75 | #: Entity/Game.php:648 Entity/Game.php:685 Entity/Game.php:790 76 | #: Entity/Game/Russianroulette.php:166 77 | msgid "Press {BUTTON} button to join." 78 | msgstr "" 79 | 80 | #: Entity/Game.php:558 Entity/Game.php:787 81 | msgid "{PLAYER_GUEST} joined..." 82 | msgstr "" 83 | 84 | #: Entity/Game.php:558 Entity/Game.php:787 85 | msgid "Waiting for {PLAYER} to start..." 86 | msgstr "" 87 | 88 | #: Entity/Game.php:558 Entity/Game.php:787 89 | msgid "Press {BUTTON} button to start." 90 | msgstr "" 91 | 92 | #: Entity/Game.php:564 93 | msgid "You cannot play with yourself!" 94 | msgstr "" 95 | 96 | #: Entity/Game.php:567 97 | msgid "This game is full!" 98 | msgstr "" 99 | 100 | #: Entity/Game.php:610 Entity/Game.php:712 Entity/Game.php:743 101 | #: Entity/Game/Checkers.php:111 Entity/Game/Checkers.php:372 102 | #: Entity/Game/Checkers.php:421 Entity/Game/Connectfour.php:87 103 | #: Entity/Game/Poolcheckers.php:80 Entity/Game/Poolcheckers.php:133 104 | #: Entity/Game/Rockpaperscissors.php:75 Entity/Game/Russianroulette.php:75 105 | #: Entity/Game/Tictactoe.php:84 106 | msgid "You're not in this game!" 107 | msgstr "" 108 | 109 | #: Entity/Game.php:623 Entity/Game.php:648 110 | msgid "{PLAYER} quit..." 111 | msgstr "" 112 | 113 | #: Entity/Game.php:623 114 | msgid "{PLAYER_HOST} is now the host." 115 | msgstr "" 116 | 117 | #: Entity/Game.php:671 Entity/Game.php:716 Entity/Game.php:761 118 | msgid "You're not the host!" 119 | msgstr "" 120 | 121 | #: Entity/Game.php:675 122 | msgid "There is no player to kick!" 123 | msgstr "" 124 | 125 | #: Entity/Game.php:685 126 | msgid "{PLAYER_GUEST} was kicked..." 127 | msgstr "" 128 | 129 | #: Entity/Game.php:837 Entity/Game.php:885 Entity/Game.php:961 130 | #: Entity/Game/Checkers.php:278 Entity/Game/Poolcheckers.php:820 131 | #: Entity/Game/Rockpaperscissors.php:298 132 | #: Entity/Game/Rockpaperscissorslizardspock.php:243 133 | #: Entity/Game/Russianroulette.php:297 Entity/Game/Russianroulette.php:312 134 | msgid "Quit" 135 | msgstr "" 136 | 137 | #: Entity/Game.php:891 Entity/Game.php:967 Entity/Game/Checkers.php:284 138 | #: Entity/Game/Poolcheckers.php:826 Entity/Game/Rockpaperscissors.php:304 139 | #: Entity/Game/Rockpaperscissorslizardspock.php:249 140 | #: Entity/Game/Russianroulette.php:303 141 | msgid "Kick" 142 | msgstr "" 143 | 144 | #: Entity/Game.php:951 Entity/Game/Checkers.php:248 145 | #: Entity/Game/Poolcheckers.php:790 Entity/Game/Rockpaperscissors.php:277 146 | #: Entity/Game/Rockpaperscissorslizardspock.php:222 147 | msgid "Play again!" 148 | msgstr "" 149 | 150 | #: Entity/Game.php:1037 151 | msgid "Game session not found or expired." 152 | msgstr "" 153 | 154 | #: Entity/Game/Checkers.php:117 Entity/Game/Checkers.php:378 155 | #: Entity/Game/Checkers.php:464 Entity/Game/Connectfour.php:121 156 | #: Entity/Game/Poolcheckers.php:86 Entity/Game/Poolcheckers.php:176 157 | #: Entity/Game/Rockpaperscissors.php:103 Entity/Game/Russianroulette.php:109 158 | #: Entity/Game/Tictactoe.php:119 159 | msgid "This game has ended!" 160 | msgstr "" 161 | 162 | #: Entity/Game/Checkers.php:129 Entity/Game/Checkers.php:154 163 | #: Entity/Game/Checkers.php:590 Entity/Game/Checkers.php:592 164 | #: Entity/Game/Connectfour.php:165 Entity/Game/Poolcheckers.php:304 165 | #: Entity/Game/Poolcheckers.php:306 Entity/Game/Russianroulette.php:137 166 | #: Entity/Game/Russianroulette.php:150 Entity/Game/Tictactoe.php:153 167 | msgid "{PLAYER} won!" 168 | msgstr "" 169 | 170 | #: Entity/Game/Checkers.php:130 Entity/Game/Checkers.php:155 171 | msgid "{PLAYER} surrendered!" 172 | msgstr "" 173 | 174 | #: Entity/Game/Checkers.php:146 Entity/Game/Checkers.php:171 175 | msgid "Press the button again to surrender!" 176 | msgstr "" 177 | 178 | #: Entity/Game/Checkers.php:258 Entity/Game/Poolcheckers.php:800 179 | msgid "Surrender" 180 | msgstr "" 181 | 182 | #: Entity/Game/Checkers.php:267 Entity/Game/Poolcheckers.php:809 183 | msgid "Vote to draw" 184 | msgstr "" 185 | 186 | #: Entity/Game/Checkers.php:406 Entity/Game/Poolcheckers.php:118 187 | msgid "You already voted!" 188 | msgstr "" 189 | 190 | #: Entity/Game/Checkers.php:486 Entity/Game/Checkers.php:509 191 | #: Entity/Game/Checkers.php:557 Entity/Game/Poolcheckers.php:197 192 | #: Entity/Game/Poolcheckers.php:220 Entity/Game/Poolcheckers.php:271 193 | msgid "You must make a jump when possible!" 194 | msgstr "" 195 | 196 | #: Entity/Game/Checkers.php:561 Entity/Game/Checkers.php:571 197 | #: Entity/Game/Connectfour.php:149 Entity/Game/Connectfour.php:154 198 | #: Entity/Game/Poolcheckers.php:275 Entity/Game/Poolcheckers.php:285 199 | #: Entity/Game/Rockpaperscissors.php:122 Entity/Game/Russianroulette.php:127 200 | #: Entity/Game/Tictactoe.php:127 Entity/Game/Tictactoe.php:143 201 | msgid "Invalid move!" 202 | msgstr "" 203 | 204 | #: Entity/Game/Checkers.php:569 Entity/Game/Poolcheckers.php:283 205 | msgid "Invalid selection!" 206 | msgstr "" 207 | 208 | #: Entity/Game/Checkers.php:575 Entity/Game/Connectfour.php:125 209 | #: Entity/Game/Poolcheckers.php:289 Entity/Game/Russianroulette.php:113 210 | #: Entity/Game/Tictactoe.php:123 211 | msgid "It's not your turn!" 212 | msgstr "" 213 | 214 | #: Entity/Game/Checkers.php:594 Entity/Game/Connectfour.php:167 215 | #: Entity/Game/Poolcheckers.php:308 Entity/Game/Tictactoe.php:155 216 | msgid "Game ended with a draw!" 217 | msgstr "" 218 | 219 | #: Entity/Game/Checkers.php:605 Entity/Game/Checkers.php:607 220 | #: Entity/Game/Poolcheckers.php:319 Entity/Game/Poolcheckers.php:321 221 | msgid "{PLAYER} voted to draw!" 222 | msgstr "" 223 | 224 | #: Entity/Game/Checkers.php:613 Entity/Game/Poolcheckers.php:327 225 | msgid "(Select the piece you want to move)" 226 | msgstr "" 227 | 228 | #: Entity/Game/Checkers.php:615 Entity/Game/Poolcheckers.php:329 229 | msgid "(Selected: {COORDINATES})" 230 | msgstr "" 231 | 232 | #: Entity/Game/Checkers.php:618 Entity/Game/Poolcheckers.php:332 233 | msgid "(Make your move or select different piece)" 234 | msgstr "" 235 | 236 | #: Entity/Game/Checkers.php:620 Entity/Game/Poolcheckers.php:334 237 | msgid "(Your move must continue)" 238 | msgstr "" 239 | 240 | #: Entity/Game/Rockpaperscissors.php:140 Entity/Game/Rockpaperscissors.php:144 241 | msgid "{PLAYER} won this round!" 242 | msgstr "" 243 | 244 | #: Entity/Game/Rockpaperscissors.php:146 245 | msgid "This round ended with a draw!" 246 | msgstr "" 247 | 248 | #: Entity/Game/Rockpaperscissors.php:155 Entity/Game/Rockpaperscissors.php:159 249 | msgid "{PLAYER} won the game!" 250 | msgstr "" 251 | 252 | #: Entity/Game/Rockpaperscissors.php:163 253 | msgid "Round {ROUND} - make your picks!" 254 | msgstr "" 255 | 256 | #: Entity/Game/Rockpaperscissors.php:166 Entity/Game/Rockpaperscissors.php:168 257 | msgid "Waiting for:" 258 | msgstr "" 259 | 260 | #: Entity/Game/Russianroulette.php:136 Entity/Game/Russianroulette.php:149 261 | msgid "{PLAYER} died! (kicked)" 262 | msgstr "" 263 | 264 | #: Entity/Game/Russianroulette.php:170 265 | msgid "{PLAYER} survived!" 266 | msgstr "" 267 | 268 | #: GameCore.php:127 269 | msgid "Database error!" 270 | msgstr "" 271 | 272 | #: GameCore.php:127 GameCore.php:257 GameCore.php:281 GameCore.php:305 273 | #: GameCore.php:329 274 | msgid "Try again in a few seconds." 275 | msgstr "" 276 | 277 | #: GameCore.php:257 278 | msgid "Process for this game is busy!" 279 | msgstr "" 280 | 281 | #: GameCore.php:281 282 | msgid "Telegram API error!" 283 | msgstr "" 284 | 285 | #: GameCore.php:305 286 | msgid "Bot error!" 287 | msgstr "" 288 | 289 | #: GameCore.php:329 290 | msgid "Unhandled error!" 291 | msgstr "" 292 | -------------------------------------------------------------------------------- /worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This runs build-in worker to periodically clean up older games 3 | 4 | # In case process crashes we it will restart indefinitely 5 | while true; do 6 | php bin/console worker 7 | 8 | echo "Worker process crashed, restarting in 60 seconds..." 9 | sleep 60 10 | done 11 | --------------------------------------------------------------------------------