diff --git a/.env.example b/.env.example index 167d3c9..e91ff31 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,10 @@ TG_GITHUB_AUTH_TOKEN='github-token' # Telegram Payments TG_PAYMENT_PROVIDER_TOKEN='123:TEST:abc' +# Support group activation expiry and ban times +TG_SUPPORT_GROUP_ACTIVATION_EXPIRE_TIME='15 min' +TG_SUPPORT_GROUP_BAN_TIME='1 day' + # URLs TG_URL_DONATE='https://...' TG_URL_PATREON='https://...' diff --git a/CHANGELOG.md b/CHANGELOG.md index 40edd61..53e191c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Exclamation symbols (:exclamation:) note something of importance e.g. breaking c ## [Unreleased] ### Added +- Rules must be agreed to before allowing a user to post in the group. ### Changed ### Deprecated ### Removed diff --git a/commands/CallbackqueryCommand.php b/commands/CallbackqueryCommand.php index 05cb8dc..44a1560 100644 --- a/commands/CallbackqueryCommand.php +++ b/commands/CallbackqueryCommand.php @@ -13,6 +13,7 @@ use Longman\TelegramBot\Commands\SystemCommand; use Longman\TelegramBot\Commands\UserCommands\DonateCommand; +use Longman\TelegramBot\Commands\UserCommands\RulesCommand; use Longman\TelegramBot\Entities\ServerResponse; /** @@ -47,18 +48,14 @@ class CallbackqueryCommand extends SystemCommand public function execute(): ServerResponse { $callback_query = $this->getCallbackQuery(); - parse_str($callback_query->getData(), $data); + parse_str($callback_query->getData(), $callback_data); - if ('donate' === $data['command']) { - DonateCommand::createPaymentInvoice( - $callback_query->getFrom()->getId(), - $data['amount'], - $data['currency'] - ); + if ('donate' === $callback_data['command']) { + return DonateCommand::handleCallbackQuery($callback_query, $callback_data); + } - return $callback_query->answer([ - 'text' => 'Awesome, an invoice has been sent to you.', - ]); + if ('rules' === $callback_data['command']) { + return RulesCommand::handleCallbackQuery($callback_query, $callback_data); } return $callback_query->answer(); diff --git a/commands/DonateCommand.php b/commands/DonateCommand.php index 22ed2ef..5af94be 100644 --- a/commands/DonateCommand.php +++ b/commands/DonateCommand.php @@ -16,6 +16,7 @@ use JsonException; use LitEmoji\LitEmoji; use Longman\TelegramBot\Commands\UserCommand; +use Longman\TelegramBot\Entities\CallbackQuery; use Longman\TelegramBot\Entities\InlineKeyboard; use Longman\TelegramBot\Entities\Payments\LabeledPrice; use Longman\TelegramBot\Entities\Payments\SuccessfulPayment; @@ -57,6 +58,27 @@ class DonateCommand extends UserCommand */ protected $private_only = true; + /** + * Handle the callback queries regarding the /donate command. + * + * @param CallbackQuery $callback_query + * @param array $callback_data + * + * @return ServerResponse + */ + public static function handleCallbackQuery(CallbackQuery $callback_query, array $callback_data): ServerResponse + { + self::createPaymentInvoice( + $callback_query->getFrom()->getId(), + $callback_data['amount'], + $callback_data['currency'] + ); + + return $callback_query->answer([ + 'text' => 'Awesome, an invoice has been sent to you.', + ]); + } + /** * @return ServerResponse * @throws TelegramException diff --git a/commands/GenericmessageCommand.php b/commands/GenericmessageCommand.php index a96e7b7..e6a236f 100644 --- a/commands/GenericmessageCommand.php +++ b/commands/GenericmessageCommand.php @@ -17,6 +17,7 @@ use Longman\TelegramBot\Commands\UserCommands\DonateCommand; use Longman\TelegramBot\Entities\ServerResponse; use Longman\TelegramBot\Exception\TelegramException; +use Longman\TelegramBot\Request; /** * Generic message command @@ -51,8 +52,12 @@ public function execute(): ServerResponse // Handle new chat members. if ($message->getNewChatMembers()) { + $this->deleteThisMessage(); // Service message. return $this->getTelegram()->executeCommand('newchatmembers'); } + if ($message->getLeftChatMember()) { + $this->deleteThisMessage(); // Service message. + } // Handle successful payment of donation. if ($payment = $message->getSuccessfulPayment()) { @@ -66,4 +71,17 @@ public function execute(): ServerResponse return parent::execute(); } + + /** + * Delete the current message. + * + * @return ServerResponse + */ + private function deleteThisMessage(): ServerResponse + { + return Request::deleteMessage([ + 'chat_id' => $this->getMessage()->getChat()->getId(), + 'message_id' => $this->getMessage()->getMessageId(), + ]); + } } diff --git a/commands/NewchatmembersCommand.php b/commands/NewchatmembersCommand.php index 9377835..07c7911 100644 --- a/commands/NewchatmembersCommand.php +++ b/commands/NewchatmembersCommand.php @@ -13,8 +13,13 @@ namespace Longman\TelegramBot\Commands\SystemCommands; +use LitEmoji\LitEmoji; use Longman\TelegramBot\Commands\SystemCommand; +use Longman\TelegramBot\DB; use Longman\TelegramBot\Entities\ChatMember; +use Longman\TelegramBot\Entities\ChatPermissions; +use Longman\TelegramBot\Entities\InlineKeyboard; +use Longman\TelegramBot\Entities\Message; use Longman\TelegramBot\Entities\ServerResponse; use Longman\TelegramBot\Entities\User; use Longman\TelegramBot\Exception\TelegramException; @@ -39,7 +44,12 @@ class NewchatmembersCommand extends SystemCommand /** * @var string */ - protected $version = '0.4.0'; + protected $version = '0.5.0'; + + /** + * @var Message + */ + private $message; /** * @var int @@ -62,17 +72,23 @@ class NewchatmembersCommand extends SystemCommand */ public function execute(): ServerResponse { - $message = $this->getMessage(); - $this->chat_id = $message->getChat()->getId(); - $this->user_id = $message->getFrom()->getId(); + $this->message = $this->getMessage(); + $this->chat_id = $this->message->getChat()->getId(); + $this->user_id = $this->message->getFrom()->getId(); - $this->group_name = $message->getChat()->getTitle(); + $this->group_name = $this->message->getChat()->getTitle(); ['users' => $new_users, 'bots' => $new_bots] = $this->getNewUsersAndBots(); // Kick bots if they weren't added by an admin. $this->kickDisallowedBots($new_bots); + // Restrict all permissions for new users. + $this->restrictNewUsers($new_users); + + // Set the joined date for all new group members. + $this->updateUsersJoinedDate($new_users); + return $this->refreshWelcomeMessage($new_users); } @@ -94,11 +110,20 @@ private function refreshWelcomeMessage(array $new_users): ServerResponse return '' . filter_var($new_user->getFirstName(), FILTER_SANITIZE_SPECIAL_CHARS) . ''; }, $new_users)); - $text = "Welcome {$new_users_text} to the {$this->group_name} group\n"; - $text .= 'Please remember that this is NOT the Telegram Support Chat.' . PHP_EOL; - $text .= 'Read the Rules that apply here.'; - - $welcome_message_sent = $this->replyToChat($text, ['parse_mode' => 'HTML', 'disable_web_page_preview' => true]); + $text = ":wave: Welcome {$new_users_text} to the {$this->group_name} group\n\n"; + $text .= 'Please read and agree to the rules before posting here, thank you!'; + + $welcome_message_sent = $this->replyToChat( + LitEmoji::encodeUnicode($text), + [ + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => true, + 'disable_notification' => true, + 'reply_markup' => new InlineKeyboard([ + ['text' => LitEmoji::encodeUnicode(':orange_book: Read the Rules'), 'url' => 'https://t.me/' . getenv('TG_BOT_USERNAME') . '?start=rules'], + ]), + ] + ); if (!$welcome_message_sent->isOk()) { return Request::emptyResponse(); } @@ -145,7 +170,7 @@ private function getNewUsersAndBots(): array $users = []; $bots = []; - foreach ($this->getMessage()->getNewChatMembers() as $member) { + foreach ($this->message->getNewChatMembers() as $member) { if ($member->getIsBot()) { $bots[] = $member; continue; @@ -177,4 +202,58 @@ private function kickDisallowedBots(array $bots): void ]); } } + + /** + * Write users join date to DB. + * + * @param array $new_users + * + * @return bool + */ + private function updateUsersJoinedDate($new_users): bool + { + $new_users_ids = array_map(static function (User $user) { + return $user->getId(); + }, $new_users); + + // Update "Joined Date" for new users. + return DB::getPdo()->prepare(" + UPDATE " . TB_USER . " + SET `joined_at` = ? + WHERE `id` IN (?) + ")->execute([date('Y-m-d H:i:s'), implode(',', $new_users_ids)]); + } + + /** + * Restrict permissions in support group for passed users. + * + * @param array $new_users + * + * @return array + */ + private function restrictNewUsers($new_users): array + { + $responses = []; + + /** @var User[] $new_users */ + foreach ($new_users as $new_user) { + $user_id = $new_user->getId(); + $responses[$user_id] = Request::restrictChatMember([ + 'chat_id' => getenv('TG_SUPPORT_GROUP_ID'), + 'user_id' => $user_id, + 'permissions' => new ChatPermissions([ + 'can_send_messages' => false, + 'can_send_media_messages' => false, + 'can_send_polls' => false, + 'can_send_other_messages' => false, + 'can_add_web_page_previews' => false, + 'can_change_info' => false, + 'can_invite_users' => false, + 'can_pin_messages' => false, + ]), + ]); + } + + return $responses; + } } diff --git a/commands/RulesCommand.php b/commands/RulesCommand.php index fd4e059..3e1d51e 100644 --- a/commands/RulesCommand.php +++ b/commands/RulesCommand.php @@ -13,8 +13,14 @@ namespace Longman\TelegramBot\Commands\UserCommands; +use LitEmoji\LitEmoji; use Longman\TelegramBot\Commands\UserCommand; +use Longman\TelegramBot\DB; +use Longman\TelegramBot\Entities\CallbackQuery; +use Longman\TelegramBot\Entities\ChatPermissions; +use Longman\TelegramBot\Entities\InlineKeyboard; use Longman\TelegramBot\Entities\ServerResponse; +use Longman\TelegramBot\Request; /** * User "/rules" command @@ -46,28 +52,109 @@ class RulesCommand extends UserCommand */ protected $private_only = true; + public static function handleCallbackQuery(CallbackQuery $callback_query, array $callback_data): ?ServerResponse + { + if ('agree' === $callback_data['action'] ?? null) { + $message = $callback_query->getMessage(); + $chat_id = $message->getChat()->getId(); + $clicked_user_id = $callback_query->getFrom()->getId(); + + // If the user is already activated, keep the initial activation date. + $activated = DB::getPdo()->prepare(" + UPDATE " . TB_USER . " + SET `activated_at` = ? + WHERE `id` = ? + AND `activated_at` IS NULL + ")->execute([date('Y-m-d H:i:s'), $clicked_user_id]); + + if (!$activated) { + return $callback_query->answer([ + 'text' => 'Something went wrong, please try again later.', + 'show_alert' => true, + ]); + } + + $give_permissions = Request::restrictChatMember([ + 'chat_id' => getenv('TG_SUPPORT_GROUP_ID'), + 'user_id' => $clicked_user_id, + 'permissions' => new ChatPermissions([ + 'can_send_messages' => true, + 'can_send_media_messages' => true, + 'can_add_web_page_previews' => true, + 'can_invite_users' => true, + ]), + ]); + + Request::editMessageReplyMarkup([ + 'chat_id' => $chat_id, + 'message_id' => $message->getMessageId(), + 'reply_markup' => new InlineKeyboard([ + ['text' => LitEmoji::encodeUnicode(':white_check_mark: Ok! Go to Bot Support group...'), 'url' => 'https://t.me/' . getenv('TG_SUPPORT_GROUP_ID')], + ]), + ]); + + return $callback_query->answer([ + 'text' => 'Thanks for agreeing to the rules. You may now post in the support group.', + 'show_alert' => true, + ]); + } + + return $callback_query->answer(); + } + /** * @inheritdoc */ public function execute(): ServerResponse { - $text = << 'markdown', + 'disable_web_page_preview' => true, + ]; - return $this->replyToChat($text, ['parse_mode' => 'markdown']); + if (!self::hasUserAgreedToRules($this->getMessage()->getFrom()->getId())) { + $text .= PHP_EOL . 'You **must agree** to these rules to post in the support group. Simply click the button below.'; + $data['reply_markup'] = new InlineKeyboard([ + ['text' => LitEmoji::encodeUnicode(':+1: I Agree to the Rules'), 'callback_data' => 'command=rules&action=agree'], + ]); + } + + return $this->replyToChat(LitEmoji::encodeUnicode($text), $data); + } + + /** + * Check if the passed user has agreed to the rules. + * + * @param int $user_id + * + * @return bool + */ + protected static function hasUserAgreedToRules(int $user_id): bool + { + $statement = DB::getPdo()->prepare(' + SELECT `activated_at` + FROM `' . TB_USER . '` + WHERE `id` = ? + AND `activated_at` IS NOT NULL + '); + $statement->execute([$user_id]); + $agreed = $statement->fetchAll(\PDO::FETCH_COLUMN, 0); + + return !empty($agreed); } } diff --git a/commands/StartCommand.php b/commands/StartCommand.php index a613234..1e8d4d4 100644 --- a/commands/StartCommand.php +++ b/commands/StartCommand.php @@ -53,6 +53,10 @@ class StartCommand extends SystemCommand */ public function execute(): ServerResponse { + if ('activate' === $this->getMessage()->getText(true)) { + return $this->getTelegram()->executeCommand('activate'); + } + if ('rules' === $this->getMessage()->getText(true)) { return $this->getTelegram()->executeCommand('rules'); } diff --git a/cron.php b/cron.php new file mode 100644 index 0000000..e3595bf --- /dev/null +++ b/cron.php @@ -0,0 +1,38 @@ +load(); + +try { + $telegram = new Telegram(getenv('TG_API_KEY'), getenv('TG_BOT_USERNAME')); + $telegram->enableMySql([ + 'host' => getenv('TG_DB_HOST'), + 'port' => getenv('TG_DB_PORT'), + 'user' => getenv('TG_DB_USER'), + 'password' => getenv('TG_DB_PASSWORD'), + 'database' => getenv('TG_DB_DATABASE'), + ]); + + // Handle expired activations. + Helpers::handleExpiredActivations(); +} catch (\Throwable $e) { + TelegramLog::error($e->getMessage()); +} diff --git a/src/Helpers.php b/src/Helpers.php index b3ada87..1c6a0fd 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -102,4 +102,56 @@ public static function saveLatestWelcomeMessage($welcome_message_id): void $new_welcome_message_ids = array_values($welcome_message_ids) + ['latest' => $welcome_message_id]; self::setSimpleOption('welcome_message_ids', $new_welcome_message_ids); } + + /** + * Handle expired activations and kick those users. + */ + public static function handleExpiredActivations(): void + { + $expiry_time = strtotime(getenv('TG_SUPPORT_GROUP_ACTIVATION_EXPIRE_TIME') ?: '15 min'); + $expiry_time_in_s = $expiry_time - time(); + + // If the user is already activated, keep the initial activation date. + $users_to_kick = DB::getPdo()->query(" + SELECT `id` + FROM " . TB_USER . " + WHERE `joined_at` < (NOW() - INTERVAL {$expiry_time_in_s} SECOND) + AND `activated_at` IS NULL + "); + foreach ($users_to_kick as $user_to_kick) { + self::kickUser((int) $user_to_kick['id']); + } + } + + /** + * Kick the passed user. + * + * @param int $user_id + * + * @return bool + */ + protected static function kickUser(int $user_id): bool + { + try { + $ban_time = strtotime(getenv('TG_SUPPORT_GROUP_BAN_TIME') ?: '1 day'); + $kick_user = Request::kickChatMember([ + 'chat_id' => getenv('TG_SUPPORT_GROUP_ID'), + 'user_id' => $user_id, + 'until_date' => $ban_time, + ]); + if ($kick_user->isOk()) { + return DB::getPdo()->prepare(" + UPDATE " . TB_USER . " + SET `activated_at` = NULL, + `joined_at` = NULL, + `kicked_at` = NOW() + WHERE `id` = ? + ")->execute([$user_id]); + } + } catch (\Throwable $e) { + // Fail silently. + } + + return false; + } } diff --git a/structure.sql b/structure.sql index 3ff4928..f1a8ebe 100644 --- a/structure.sql +++ b/structure.sql @@ -8,3 +8,8 @@ CREATE TABLE IF NOT EXISTS `simple_options` ( PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci; + +-- Version Unreleased +ALTER TABLE `user` ADD COLUMN `joined_at` TIMESTAMP NULL COMMENT 'Timestamp when the user joined the support group.'; +ALTER TABLE `user` ADD COLUMN `kicked_at` TIMESTAMP NULL COMMENT 'Timestamp when the user was kicked from the support group.'; +ALTER TABLE `user` ADD COLUMN `activated_at` TIMESTAMP NULL COMMENT 'Timestamp when the user has agreed to the rules and has been allowed to post in the support group.';