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'),
];
}
}