HEX
Server: Apache
System: Linux server3230.server-vps.com 5.15.0-46-generic #49-Ubuntu SMP Thu Aug 4 18:03:25 UTC 2022 x86_64
User: lzgqnjwu (1002)
PHP: 7.4.33
Disabled: NONE
Upload Files
File: /home/lzgqnjwu/sites/bot.sgt.com.ua/common/models/BotCommands.php
<?php

namespace common\models;

use Yii;

/**
 * This is the model class for table "bot_commands".
 *
 * @property int $id
 * @property int|null $bot_id
 * @property int|null $user_id
 * @property string|null $name
 * @property string|null $command
 * @property string|null $message
 * @property int $visible
 * @property int $telegram_menu Show in Telegram "/" menu (opt-in for /reg, /docs, /tracking)
 */
class BotCommands extends \yii\db\ActiveRecord
{
    /**
     * {@inheritdoc}
     */
    public static function tableName()
    {
        return 'bot_commands';
    }

    /**
     * {@inheritdoc}
     */
    public function rules()
    {
        return [
            [['bot_id', 'user_id', 'name', 'command', 'message'], 'required'],
            [['bot_id', 'user_id', 'visible', 'telegram_menu'], 'integer'],
            [['message'], 'string'],
            [['name', 'command'], 'string', 'max' => 255],
            [['telegram_menu'], 'boolean'],
            [['telegram_menu'], 'default', 'value' => 0],
            [['command'], 'validateCommandFormat'],
        ];
    }

    public function validateCommandFormat(string $attribute): void
    {
        $normalized = self::normalizeCommandValue($this->$attribute);
        if ($normalized === '' || !preg_match('#^/[a-z0-9_]{1,32}$#', $normalized)) {
            $this->addError($attribute, Yii::t('common', 'Command must look like /help (Latin letters, digits, underscore).'));
        }
    }

    /**
     * {@inheritdoc}
     */
    public function beforeValidate(): bool
    {
        if ($this->command !== null && $this->command !== '') {
            $this->command = self::normalizeCommandValue($this->command);
        }
        if ($this->isNewRecord && ($this->visible === null || $this->visible === '')) {
            $this->visible = 1;
        }

        return parent::beforeValidate();
    }

    /**
     * @param string|null $command
     */
    public static function normalizeCommandValue(?string $command): string
    {
        $command = trim(mb_strtolower((string) $command));
        if ($command === '') {
            return '';
        }
        if (!str_starts_with($command, '/')) {
            $command = '/' . $command;
        }
        if (str_contains($command, '@')) {
            $command = explode('@', $command, 2)[0];
        }

        return $command;
    }

    /**
     * Pushes command list to Telegram (menu when user types /).
     *
     * @return array{ok: bool, count: int, error: string|null, telegram_count: int|null}
     */
    public static function syncTelegramMenu(int $botId): array
    {
        $result = ['ok' => false, 'count' => 0, 'error' => null, 'telegram_count' => null];

        $bot = Bot::findOne(['id' => $botId, 'visible' => 1]);
        if ($bot === null || empty($bot->token) || (int) $bot->bot_type !== Bot::TELEGRAM_TYPE) {
            $result['error'] = 'Bot unavailable';

            return $result;
        }

        $token = trim((string) $bot->token);
        self::ensureMvpCommandsInDb($botId);
        $apiCommands = self::buildApiCommands($botId);
        $result['count'] = count($apiCommands);

        // Clear stale BotFather / language-specific lists before applying the new menu.
        self::clearTelegramCommandScopes($token);

        if ($apiCommands === []) {
            $result['ok'] = true;

            return $result;
        }

        $scopes = [
            ['type' => 'default'],
            ['type' => 'all_private_chats'],
        ];
        // uk first — Telegram client picks language-specific list for UA users
        $languageCodes = ['uk', null, 'ru', 'en'];

        $failures = [];
        $successes = 0;
        foreach ($scopes as $scope) {
            foreach ($languageCodes as $languageCode) {
                $payload = [
                    'commands' => $apiCommands,
                    'scope'    => $scope,
                ];
                if ($languageCode !== null) {
                    $payload['language_code'] = $languageCode;
                }

                $response = self::telegramApiPost($token, 'setMyCommands', $payload);
                if ($response['ok']) {
                    $successes++;
                    continue;
                }

                $scopeLabel = $scope['type'] . ($languageCode ? ':' . $languageCode : '');
                $failures[] = $scopeLabel . ': ' . ($response['error'] ?? 'Telegram API error');
            }
        }

        if ($successes === 0) {
            $result['error'] = implode('; ', $failures);
            Yii::warning('setMyCommands failed bot_id=' . $botId . ' ' . $result['error'], __METHOD__);

            return $result;
        }

        if ($failures !== []) {
            Yii::warning('setMyCommands partial failures bot_id=' . $botId . ' ' . implode('; ', $failures), __METHOD__);
        }

        $menuButton = self::telegramApiPost($token, 'setChatMenuButton', [
            'menu_button' => ['type' => 'commands'],
        ]);
        if (!$menuButton['ok']) {
            Yii::warning('setChatMenuButton failed bot_id=' . $botId . ' ' . ($menuButton['error'] ?? ''), __METHOD__);
        }

        $verify = self::telegramApiPost($token, 'getMyCommands', [
            'scope'         => ['type' => 'all_private_chats'],
            'language_code' => 'uk',
        ]);
        if ($verify['ok'] && is_array($verify['result'])) {
            $result['telegram_count'] = count($verify['result']);
        }

        $result['ok'] = true;

        return $result;
    }

    /**
     * Commands hidden from Telegram menu unless telegram_menu=1 on that bot's command row.
     *
     * @return string[]
     */
    public static function hiddenTelegramMenuCommands(): array
    {
        return ['/reg', '/docs', '/tracking'];
    }

    public static function isManagedHiddenTelegramCommand(?string $command): bool
    {
        $normalized = self::normalizeCommandValue($command);

        return $normalized !== '' && in_array($normalized, self::hiddenTelegramMenuCommands(), true);
    }

    public static function supportsTelegramMenuFlag(): bool
    {
        $schema = static::getTableSchema();

        return $schema !== null && isset($schema->columns['telegram_menu']);
    }

    public static function shouldPublishToTelegramMenu(self $row): bool
    {
        if ((int) $row->visible !== 1) {
            return false;
        }
        if (!self::isManagedHiddenTelegramCommand((string) $row->command)) {
            return true;
        }
        if (!self::supportsTelegramMenuFlag()) {
            return false;
        }

        return (int) $row->telegram_menu === 1;
    }

    /**
     * @return list<array{command: string, description: string}>
     */
    protected static function buildApiCommands(int $botId): array
    {
        $apiCommands = [];
        foreach (self::find()->where(['bot_id' => $botId, 'visible' => 1])->orderBy(['id' => SORT_ASC])->all() as $row) {
            if (!self::shouldPublishToTelegramMenu($row)) {
                continue;
            }
            $slug = ltrim(self::normalizeCommandValue($row->command), '/');
            if ($slug === '' || !preg_match('/^[a-z0-9_]{1,32}$/', $slug)) {
                continue;
            }
            $desc = trim((string) ($row->name !== '' && $row->name !== null ? $row->name : $slug));
            if (mb_strlen($desc) < 3) {
                $desc = $slug . ' cmd';
            }
            $apiCommands[] = [
                'command'     => $slug,
                'description' => mb_substr($desc, 0, 256),
            ];
        }

        return $apiCommands;
    }

    /**
     * @return array{ok: bool, result: mixed, error: string|null}
     */
    protected static function telegramApiPost(string $token, string $method, array $payload): array
    {
        $url = 'https://api.telegram.org/bot' . $token . '/' . $method;
        $ch = curl_init($url);
        if ($ch === false) {
            return ['ok' => false, 'result' => null, 'error' => 'curl init failed'];
        }

        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode($payload, JSON_UNESCAPED_UNICODE),
            CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 15,
        ]);
        $raw = curl_exec($ch);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($raw === false || $curlError !== '') {
            return ['ok' => false, 'result' => null, 'error' => $curlError !== '' ? $curlError : 'empty response'];
        }

        $decoded = json_decode((string) $raw, true);
        if (!is_array($decoded) || empty($decoded['ok'])) {
            return [
                'ok'     => false,
                'result' => null,
                'error'  => is_array($decoded) ? (string) ($decoded['description'] ?? 'Telegram API error') : 'bad JSON',
            ];
        }

        return ['ok' => true, 'result' => $decoded['result'] ?? null, 'error' => null];
    }

    /**
     * Creates MVP bot commands in DB (no Telegram sync).
     */
    public static function ensureMvpCommandsInDb(int $botId): void
    {
        $bot = Bot::findOne(['id' => $botId, 'visible' => 1]);
        if ($bot === null) {
            return;
        }

        $userId = (int) $bot->user_id;
        if (!self::shouldAutoCommandsBeEnabled($bot)) {
            self::removeManagedCommandIfExists($botId, '/reg');
            self::removeManagedCommandIfExists($botId, '/tracking');
            self::removeManagedCommandIfExists($botId, '/docs');

            return;
        }
        self::createCommandRecordIfMissing($botId, $userId, '/reg', 'Reg command menu', 'Reg command welcome');
        if (Bot::supportsTracking()) {
            self::createCommandRecordIfMissing($botId, $userId, '/tracking', 'Tracking command menu', 'Tracking command welcome');
        }
        if (Bot::supportsApprovals()) {
            self::createCommandRecordIfMissing($botId, $userId, '/docs', 'Docs command menu', 'Approval intro');
        }
        if ($bot->isRemindersEnabled()) {
            self::createCommandRecordIfMissing($botId, $userId, '/reminder', 'Reminder command menu', 'Reminder command placeholder');
        }
        if ($bot->isNotesEnabled()) {
            self::createCommandRecordIfMissing($botId, $userId, '/note', 'Note command menu', 'Note command welcome');
        }
    }

    /**
     * @return bool true when command exists or was created
     */
    protected static function createCommandRecordIfMissing(
        int $botId,
        int $userId,
        string $command,
        string $nameKey,
        string $messageKey
    ): bool {
        $normalized = self::normalizeCommandValue($command);
        if ($normalized === '') {
            return false;
        }

        $existing = self::find()
            ->where(['bot_id' => $botId, 'command' => $normalized])
            ->one();
        if ($existing !== null) {
            return true;
        }

        $prevLang = Yii::$app->language;
        Yii::$app->language = 'uk';
        $cmd = new self();
        $cmd->bot_id = $botId;
        $cmd->user_id = $userId;
        $cmd->name = Yii::t('common', $nameKey);
        $cmd->command = $normalized;
        $cmd->message = Yii::t('common', $messageKey);
        $cmd->visible = 1;
        if (self::supportsTelegramMenuFlag()) {
            $cmd->telegram_menu = 0;
        }
        Yii::$app->language = $prevLang;

        if (!$cmd->save()) {
            Yii::warning(
                'createCommandRecordIfMissing failed bot_id=' . $botId . ' cmd=' . $normalized . ' '
                . json_encode($cmd->errors, JSON_UNESCAPED_UNICODE),
                __METHOD__
            );

            return false;
        }

        return true;
    }

    /**
     * Ensures /reminder appears in Telegram menu when reminders module is enabled.
     */
    public static function ensureReminderCommand(int $botId): void
    {
        $bot = Bot::findOne(['id' => $botId, 'visible' => 1]);
        if ($bot === null || !$bot->isRemindersEnabled()) {
            return;
        }

        self::ensureMvpCommandsInDb($botId);
        self::syncTelegramMenu($botId);
    }

    /**
     * Ensures /note appears in Telegram menu when notes module is enabled.
     */
    public static function ensureNoteCommand(int $botId): void
    {
        $bot = Bot::findOne(['id' => $botId, 'visible' => 1]);
        if ($bot === null || !$bot->isNotesEnabled()) {
            return;
        }

        self::ensureMvpCommandsInDb($botId);
        self::syncTelegramMenu($botId);
    }

    /**
     * Ensures /tracking appears in Telegram menu when tracking module is available.
     */
    public static function ensureTrackingCommand(int $botId): void
    {
        $bot = Bot::findOne(['id' => $botId, 'visible' => 1]);
        if ($bot === null || !Bot::supportsTracking() || !self::shouldAutoCommandsBeEnabled($bot)) {
            return;
        }

        self::ensureMvpCommandsInDb($botId);
        self::syncTelegramMenu($botId);
    }

    protected static function shouldAutoCommandsBeEnabled(Bot $bot): bool
    {
        return $bot->isRequireRegistrationForCommands();
    }

    /**
     * Removes all bot command menu entries in Telegram (all scopes/languages we use).
     */
    protected static function clearTelegramCommandScopes(string $token): void
    {
        $scopes = [
            ['type' => 'default'],
            ['type' => 'all_private_chats'],
        ];
        $languageCodes = ['uk', null, 'ru', 'en'];

        foreach ($scopes as $scope) {
            foreach ($languageCodes as $languageCode) {
                $payload = ['scope' => $scope];
                if ($languageCode !== null) {
                    $payload['language_code'] = $languageCode;
                }
                self::telegramApiPost($token, 'deleteMyCommands', $payload);
            }
        }
    }

    protected static function removeManagedCommandIfExists(int $botId, string $command): void
    {
        $normalized = self::normalizeCommandValue($command);
        if ($normalized === '') {
            return;
        }
        self::deleteAll([
            'bot_id' => $botId,
            'command' => $normalized,
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function attributeLabels()
    {
        return [
            'id' => Yii::t('common', 'ID'),
            'bot_id' => Yii::t('common', 'Bot ID'),
            'user_id' => Yii::t('common', 'User ID'),
            'name' => Yii::t('common', 'Name'),
            'command' => Yii::t('common', 'Command'),
            'message' => Yii::t('common', 'Message'),
            'visible' => Yii::t('common', 'Visible'),
            'telegram_menu' => Yii::t('common', 'Show in Telegram menu'),
        ];
    }
}