diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0fe41f..cc607ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ All notable changes to `telegraph` will be documented in this file. +## v1.55.1 - 2024-10-03 + +### What's Changed + +* Added `ReactionType` data type, fixed typing by @andrey-helldar in https://github.com/defstudio/telegraph/pull/652 + +**Full Changelog**: https://github.com/defstudio/telegraph/compare/v1.55.0...v1.55.1 + +## v1.55.0 - 2024-10-03 + +### What's Changed + +* Added entities processing by @andrey-helldar in https://github.com/defstudio/telegraph/pull/650 + +**Full Changelog**: https://github.com/defstudio/telegraph/compare/v1.54.0...v1.55.0 + +## v1.54.0 - 2024-10-03 + +### What's Changed + +* Added independent methods for saving a chat and forming its name by @andrey-helldar in https://github.com/defstudio/telegraph/pull/647 +* [feat] reactions by @Anze in https://github.com/defstudio/telegraph/pull/641 +* Adding reaction processing when retrieving in webhooks by @andrey-helldar in https://github.com/defstudio/telegraph/pull/649 +* Fix detection of command parameter without passing it by @andrey-helldar in https://github.com/defstudio/telegraph/pull/651 + +### New Contributors + +* @Anze made their first contribution in https://github.com/defstudio/telegraph/pull/641 + +**Full Changelog**: https://github.com/defstudio/telegraph/compare/v1.53.0...v1.54.0 + +## v1.53.0 - 2024-09-16 + +### What's Changed + +* #635 implement config for connection request timeout by @MarioGattolla in https://github.com/defstudio/telegraph/pull/636 +* Added the ability to specify different prefixes for command definitions by @andrey-helldar in https://github.com/defstudio/telegraph/pull/643 +* Make protected methods by @andrey-helldar in https://github.com/defstudio/telegraph/pull/642 + +**Full Changelog**: https://github.com/defstudio/telegraph/compare/v1.52.0...v1.53.0 + +## v1.52.0 - 2024-08-27 + +### What's Changed + +* [feat] add settings to registerWebhook method by @fabio-ivona and @MarcusLestrange in https://github.com/defstudio/telegraph/pull/634 + +**Full Changelog**: https://github.com/defstudio/telegraph/compare/v1.51.0...v1.52.0 + ## v1.51.0 - 2024-08-26 ### What's Changed diff --git a/config/telegraph.php b/config/telegraph.php index 7da4536e..5c0d9f31 100644 --- a/config/telegraph.php +++ b/config/telegraph.php @@ -60,6 +60,16 @@ */ 'max_connections' => env('TELEGRAPH_WEBHOOK_MAX_CONNECTIONS', 40), + /** + * List of event types for which the webhook should fire. + * + * Specify a null to receive all update types except `chat_member`, `message_reaction`, + * and `message_reaction_count` (by default). + * + * @see https://core.telegram.org/bots/api#setwebhook + */ + 'allowed_updates' => null, + /* * If enabled, Telegraph dumps received * webhook messages to logs @@ -72,6 +82,11 @@ */ 'http_timeout' => env('TELEGRAPH_HTTP_TIMEOUT', 30), + /* + * Sets HTTP connection request timeout when interacting with Telegram servers + */ + 'http_connection_timeout' => env('TELEGRAPH_HTTP_CONNECTION_TIMEOUT', 10), + 'security' => [ /* * if enabled, allows callback queries from unregistered chats @@ -181,6 +196,18 @@ ], ], + /* + * Sets preferences for commands + */ + 'commands' => [ + /* + * Defines a list of characters that are the identifier of a command sent to the chat. + * + * Default is `/` + */ + 'start_with' => ['/'], + ], + 'payments' => [ 'provider_token' => env('TELEGRAPH_PAYMENT_PROVIDER_TOKEN', ''), ], diff --git a/docs/12.features/9.dto.md b/docs/12.features/9.dto.md index 1477fb9c..8b02e849 100644 --- a/docs/12.features/9.dto.md +++ b/docs/12.features/9.dto.md @@ -10,6 +10,7 @@ contains incoming data (a message or a callback query) - `->id()` incoming _update_id_ - `->message()` (optional) an instance of [`Message`](#message) +- `->messageReaction()` (optional) an instance of [`Reaction`](#reaction) - `->callbackQuery()` (optional) an instance of [`CallbackQuery`](#callback-query) ## `Chat` @@ -42,6 +43,7 @@ contains incoming data (a message or a callback query) - `->contact()` (optional) an instance of [`Contact`](#contact) holding data about the contained contact data - `->voice()` (optional) an instance of [`Voice`](#voice) holding data about the contained voical message - `->sticker()` (optional) an instance of [`Sticker`](#sticker) holding data about the contained sticker +- `->entities()` (optional) a collection of [`Entity`](#entity) holding data about the contained entity - `->invoice()` (optional) an instance of [`Invoice`](#invoice) holding data about the contained invoice - `->newChatMembers()` a collection of [`User`](#user) holding the list of users that joined the group/supergroup - `->leftChatMember()` (optional) an instance of [`User`](#user) holding data about the user that left the group/supergroup @@ -58,6 +60,22 @@ contains incoming data (a message or a callback query) - `->message()` (optional) an instance of the [`Message`](#message) that triggered the callback query - `->data()` an `Illuminate\Support\Collection` that holds the key/value pairs of the callback query data +## `Reaction` + +- `->id()` incoming _message_id_ +- `->chat()` an instance of [`Chat`](#chat) holding data about the chat to which the message belongs to +- `->actorChat()` (optional) an instance of [`Chat`](#chat) holding data about the chat to which the chat on behalf of which the reaction was changed, if the user is anonymous +- `->from()` (optional) an instance of [`User`](#user) holding data about the message's sender +- `->oldReaction()` a collection of [`ReactionType`](#reactiontype) holding data about the contained reaction type resolutions +- `->newReaction()` a collection of [`ReactionType`](#reactiontype) holding data about the contained reaction type resolutions +- `->date()` a `CarbonInterface` holding the message sent + +## `ReactionType` + +- `->type()` type of the reaction +- `->emoji()` reaction emoji +- `->customEmojiId()` (optional) custom emoji identifier + ## `User` @@ -157,6 +175,16 @@ contains incoming data (a message or a callback query) - `->filesize()` (optional) sticker file size in Bytes - `->thumbnail()` (optional) an instance of the [`Photo`](#photo) that holds data about the thumbnail +## `Entity` + +- `->type()` type of the entity +- `->offset()` offset in UTF-16 code units to the start of the entity +- `->length()` length of the entity in utf-16 code units +- `->url()` (optional) for “text_link” only, URL that will be opened after user taps on the text +- `->user()` (optional) for “text_mention” only, the mentioned [`User`](#user) +- `->language()` (optional) for “pre” only, the programming language of the entity text +- `->customEmojiId()` (optional) for “custom_emoji” only, unique identifier of the custom emoji + ## `WriteAccessAllowed` - `->fromRequest()` true, if the access was granted after the user accepted an explicit request from a Web App sent by the method [requestWriteAccess](https://core.telegram.org/bots/webapps#initializing-mini-apps) diff --git a/docs/13.api/2.chats.md b/docs/13.api/2.chats.md index a720616d..93707241 100644 --- a/docs/13.api/2.chats.md +++ b/docs/13.api/2.chats.md @@ -75,7 +75,7 @@ replace a message keyboard (see [keyboards](features/keyboards) for details) ```php Telegraph::replaceKeyboard( - $messageId, + $messageId, Keyboard::make()->buttons([ Button::make('open')->url('https://test.dev') ]) @@ -98,6 +98,30 @@ sets chat description Telegraph::setDescription("a test chat with my bot")->send(); ``` +## `setMessageReaction()` + +changes the chosen reactions on a message + +```php +Telegraph::setMessageReaction($messageId, ['type' => 'emoji', 'emoji' => '👍'])->send(); +``` + +## `reactWithEmoji()` + +reaction on a message with emoji + +```php +Telegraph::reactWithEmoji($messageId, '👍')->send(); +``` + +## `reactWithCustomEmoji()` + +reaction on a message with custom emoji + +```php +Telegraph::reactWithCustomEmoji($messageId, '12312')->send(); +``` + ## `setChatPhoto()` sets chat profile photo @@ -122,9 +146,9 @@ title: my telegram group Set menu button. For detailed info, see docs [here](https://core.telegram.org/bots/api#menubutton) and [here](https://core.telegram.org/bots/api#setchatmenubutton) ```php -Telegraph::setChatMenuButton()->default()->send(); //restore default -Telegraph::setChatMenuButton()->commands()->send(); //show bot commands in menu button -Telegraph::setChatMenuButton()->webApp("Web App", "https://my-web.app")->send(); //show start web app button +Telegraph::setChatMenuButton()->default()->send(); //restore default +Telegraph::setChatMenuButton()->commands()->send(); //show bot commands in menu button +Telegraph::setChatMenuButton()->webApp("Web App", "https://my-web.app")->send(); //show start web app button ``` > [!WARNING] diff --git a/docs/14.models/2.telegraph-chat.md b/docs/14.models/2.telegraph-chat.md index d2af2fe7..78962314 100644 --- a/docs/14.models/2.telegraph-chat.md +++ b/docs/14.models/2.telegraph-chat.md @@ -65,7 +65,7 @@ Retrieves the chat member info from telegram ```php /** @var \DefStudio\Telegraph\Models\TelegraphChat $telegraphChat */ -$telegraphChat->memberInfo('user_id'); +$telegraphChat->memberInfo('user_id'); /* status: string @@ -177,7 +177,7 @@ Starts a `Telegraph` call to replace a message keyboard (see [keyboards](feature /** @var \DefStudio\Telegraph\Models\TelegraphChat $telegraphChat */ $telegraphChat->replaceKeyboard( - $messageId, + $messageId, Keyboard::make()->buttons([ Button::make('open')->url('https://test.dev') ]) @@ -313,7 +313,41 @@ use DefStudio\Telegraph\Models\TelegraphChat; $telegraphChat->setDescription("a test chat with my bot")->send(); ``` +## `setMessageReaction()` +changes the chosen reactions on a message + +```php +use DefStudio\Telegraph\Models\TelegraphChat; + +/** @var TelegraphChat $telegraphChat */ + +$telegraphChat->setMessageReaction($messageId, ['type' => 'emoji', 'emoji' => '👍'])->send(); +``` + +## `reactWithEmoji()` + +reaction on a message with emoji + +```php +use DefStudio\Telegraph\Models\TelegraphChat; + +/** @var TelegraphChat $telegraphChat */ + +$telegraphChat->reactWithEmoji($messageId, '👍')->send(); +``` + +## `reactWithCustomEmoji()` + +reaction on a message with custom emoji + +```php +use DefStudio\Telegraph\Models\TelegraphChat; + +/** @var TelegraphChat $telegraphChat */ + +$telegraphChat->reactWithCustomEmoji($messageId, '12312')->send(); +``` ## `setChatPhoto()` @@ -536,9 +570,9 @@ set chat menu button ```php /** @var DefStudio\Telegraph\Models\TelegraphChat $telegraphChat */ -$telegraphChat->setMenuButton()->default()->send(); //restore default -$telegraphChat->setMenuButton()->commands()->send(); //show bot commands in menu button -$telegraphChat->setMenuButton()->webApp("Web App", "https://my-web.app")->send(); //show start web app button +$telegraphChat->setMenuButton()->default()->send(); //restore default +$telegraphChat->setMenuButton()->commands()->send(); //show bot commands in menu button +$telegraphChat->setMenuButton()->webApp("Web App", "https://my-web.app")->send(); //show start web app button ``` # `Attachments` diff --git a/docs/15.webhooks/4.webhook-request-types.md b/docs/15.webhooks/4.webhook-request-types.md index 49e7c3f4..267a3949 100644 --- a/docs/15.webhooks/4.webhook-request-types.md +++ b/docs/15.webhooks/4.webhook-request-types.md @@ -4,7 +4,7 @@ navigation.title: 'Request Types' --- -Telegraph can handle four incoming webhook request types: **Chat Messages**, **Chat Commands**, **Callback Queries** and **Inline Queries**: +Telegraph can handle four incoming webhook request types: **Chat Messages**, **Message Reactions**, **Chat Commands**, **Callback Queries** and **Inline Queries**: ## Chat Messages @@ -46,6 +46,27 @@ class CustomWebhookHandler extends WebhookHandler } ``` +## Message Reactions + +Chat messages containing adding and/or deleting user reactions (emojis) to messages. + +It can be handled by overriding `DefStudio\Telegraph\Handlers\WebhookHandler::handleChatReaction()` method: + +```php +class CustomWebhookHandler extends WebhookHandler +{ + protected function handleChatReaction(array $newReactions, array $oldReactions): void + { + // in this example, a received emoji message is sent back to the chat + $this->chat->html("Received: " . $newReactions[0]['emoji'])->send(); + } +} +``` + +> [!WARNING] +> By default, Telegram does not report events related to reactions to messages. +> To interact with reactions, [specify](config/telegraph.php) in the settings all +> [types of messages](https://core.telegram.org/bots/api#update) for which you want to catch events. ## Chat Commands diff --git a/docs/2.installation.md b/docs/2.installation.md index 5aeac11f..79176bcb 100644 --- a/docs/2.installation.md +++ b/docs/2.installation.md @@ -89,7 +89,11 @@ return [ * Sets HTTP request timeout when interacting with Telegram servers */ 'http_timeout' => 30, - + + /* + * Sets HTTP connection request timeout when interacting with Telegram servers + */ + 'http_connection_timeout' => 10, 'security' => [ /* diff --git a/src/Concerns/FakesRequests.php b/src/Concerns/FakesRequests.php index 8ef322f3..546aadc3 100644 --- a/src/Concerns/FakesRequests.php +++ b/src/Concerns/FakesRequests.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Http\Client\Response; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Illuminate\Support\Testing\Fakes\QueueFake; @@ -262,9 +263,31 @@ public function dumpSentData(): void /** @phpstan-ignore-next-line */ public static function assertSentData(string $endpoint, array $data = null, bool $exact = true): void { - $foundMessages = collect(self::$sentMessages); + if ($data == null) { + $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint (sent %d requests so far)", $endpoint, count(self::$sentMessages)); + } else { + $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint with the given data (sent %d requests so far)", $endpoint, count(self::$sentMessages)); + } + + Assert::assertNotEmpty(static::searchMessages($endpoint, $data, $exact)->toArray(), $errorMessage); + } - $foundMessages = $foundMessages + /** @phpstan-ignore-next-line */ + public static function assertNotSentData(string $endpoint, array $data = null, bool $exact = true): void + { + if ($data == null) { + $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint (sent %d requests so far)", $endpoint, count(self::$sentMessages)); + } else { + $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint with the given data (sent %d requests so far)", $endpoint, count(self::$sentMessages)); + } + + Assert::assertEmpty(static::searchMessages($endpoint, $data, $exact)->toArray(), $errorMessage); + } + + /** @phpstan-ignore-next-line */ + protected static function searchMessages(string $endpoint, array $data = null, bool $exact = true): Collection + { + return collect(self::$sentMessages) ->filter(fn (array $message): bool => $message['endpoint'] == $endpoint) ->filter(function (array $message) use ($data, $exact): bool { foreach ($data ?? [] as $key => $value) { @@ -287,14 +310,5 @@ public static function assertSentData(string $endpoint, array $data = null, bool return true; }); - - - if ($data == null) { - $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint (sent %d requests so far)", $endpoint, count(self::$sentMessages)); - } else { - $errorMessage = sprintf("Failed to assert that a request was sent to [%s] endpoint with the given data (sent %d requests so far)", $endpoint, count(self::$sentMessages)); - } - - Assert::assertNotEmpty($foundMessages->toArray(), $errorMessage); } } diff --git a/src/Concerns/HasBotsAndChats.php b/src/Concerns/HasBotsAndChats.php index 94be6192..734a56bb 100644 --- a/src/Concerns/HasBotsAndChats.php +++ b/src/Concerns/HasBotsAndChats.php @@ -341,6 +341,32 @@ public function setChatPhoto(string $path): Telegraph return $telegraph; } + /** + * @param array $reaction + */ + public function setMessageReaction(int $messageId, array $reaction, bool $isBig = false): Telegraph + { + $telegraph = clone $this; + + $telegraph->endpoint = self::ENDPOINT_SET_MESSAGE_REACTION; + $telegraph->data['chat_id'] = $telegraph->getChatId(); + $telegraph->data['message_id'] = $messageId; + $telegraph->data['reaction'] = json_encode([$reaction]); + $telegraph->data['is_big'] = $isBig; + + return $telegraph; + } + + public function reactWithEmoji(int $messageId, string $emoji, bool $isBig = false): Telegraph + { + return $this->setMessageReaction($messageId, ['type' => 'emoji', 'emoji' => $emoji], $isBig); + } + + public function reactWithCustomEmoji(int $messageId, string $customEmoji, bool $isBig = false): Telegraph + { + return $this->setMessageReaction($messageId, ['type' => 'custom_emoji', 'emoji' => $customEmoji], $isBig); + } + public function deleteChatPhoto(): Telegraph { $telegraph = clone $this; diff --git a/src/Concerns/InteractsWithTelegram.php b/src/Concerns/InteractsWithTelegram.php index 7733d249..45b6692f 100644 --- a/src/Concerns/InteractsWithTelegram.php +++ b/src/Concerns/InteractsWithTelegram.php @@ -41,7 +41,7 @@ function ($request, Attachment $attachment, string $key) { ); /** @phpstan-ignore-next-line */ - return $request->timeout(config('telegraph.http_timeout', 30))->post($this->getApiUrl(), $this->prepareData()); + return $request->timeout(config('telegraph.http_timeout', 30))->connectTimeout(config('telegraph.http_connection_timeout', 10))->post($this->getApiUrl(), $this->prepareData()); } /** diff --git a/src/Concerns/InteractsWithWebhooks.php b/src/Concerns/InteractsWithWebhooks.php index 128c8f3d..07b64e12 100644 --- a/src/Concerns/InteractsWithWebhooks.php +++ b/src/Concerns/InteractsWithWebhooks.php @@ -29,7 +29,16 @@ private function getWebhookUrl(): string return $customWebhookUrl.route('telegraph.webhook', $this->getBot(), false); } - public function registerWebhook(bool $dropPendingUpdates = null, int $maxConnections = null, string $secretToken = null): Telegraph + /** + * @param bool|null $dropPendingUpdates + * @param int|null $maxConnections + * @param string|null $secretToken + * @param string[]|null $allowedUpdates + * + * @throws \DefStudio\Telegraph\Exceptions\TelegramWebhookException + * @return \DefStudio\Telegraph\Telegraph + */ + public function registerWebhook(bool $dropPendingUpdates = null, int $maxConnections = null, string $secretToken = null, array $allowedUpdates = null): Telegraph { $telegraph = clone $this; @@ -39,6 +48,7 @@ public function registerWebhook(bool $dropPendingUpdates = null, int $maxConnect 'drop_pending_updates' => $dropPendingUpdates, 'max_connections' => $maxConnections ?? config('telegraph.webhook.max_connections'), 'secret_token' => $secretToken ?? config('telegraph.webhook.secret_token'), + 'allowed_updates' => $allowedUpdates ?? config('telegraph.webhook.allowed_updates'), ])->filter() ->toArray(); diff --git a/src/DTO/Entity.php b/src/DTO/Entity.php new file mode 100644 index 00000000..8780aa37 --- /dev/null +++ b/src/DTO/Entity.php @@ -0,0 +1,120 @@ +> + */ +class Entity implements Arrayable +{ + private string $type; + + private int $offset; + + private int $length; + + private ?string $url = null; + + private ?User $user = null; + + private ?string $language = null; + + private ?string $customEmojiId = null; + + private function __construct() + { + } + + /** + * @param array{ + * type: string, + * offset: int, + * length: int, + * url?: string, + * user?: array, + * language?: string, + * custom_emoji_id?: string + * } $data + * + * @return \DefStudio\Telegraph\DTO\Entity + */ + public static function fromArray(array $data): Entity + { + $entity = new self(); + + $entity->type = $data['type']; + $entity->offset = $data['offset']; + $entity->length = $data['length']; + + if (isset($data['url'])) { + $entity->url = $data['url']; + } + + if (isset($data['user'])) { + /* @phpstan-ignore-next-line */ + $entity->user = User::fromArray($data['user']); + } + + if (isset($data['language'])) { + $entity->language = $data['language']; + } + + if (isset($data['custom_emoji_id'])) { + $entity->customEmojiId = (string) $data['custom_emoji_id']; + } + + return $entity; + } + + public function type(): string + { + return $this->type; + } + + public function offset(): int + { + return $this->offset; + } + + public function length(): int + { + return $this->length; + } + + public function url(): ?string + { + return $this->url; + } + + public function user(): ?User + { + return $this->user; + } + + public function language(): ?string + { + return $this->language; + } + + public function customEmojiId(): ?string + { + return $this->customEmojiId; + } + + public function toArray(): array + { + return array_filter([ + 'type' => $this->type, + 'offset' => $this->offset, + 'length' => $this->length, + 'url' => $this->url, + 'user' => $this->user()?->toArray(), + 'language' => $this->language, + 'custom_emoji_id' => $this->customEmojiId, + ], fn ($value) => $value !== null); + } +} diff --git a/src/DTO/Message.php b/src/DTO/Message.php index 2cfab8d6..7509f894 100644 --- a/src/DTO/Message.php +++ b/src/DTO/Message.php @@ -55,9 +55,13 @@ class Message implements Arrayable private ?WriteAccessAllowed $writeAccessAllowed = null; + /** @var Collection */ + private Collection $entities; + private function __construct() { $this->photos = Collection::empty(); + $this->entities = Collection::empty(); } /** @@ -88,6 +92,7 @@ private function __construct() * left_chat_member?: array, * web_app_data?: array, * write_access_allowed?: array, + * entities?: array * } $data */ public static function fromArray(array $data): Message @@ -210,6 +215,11 @@ public static function fromArray(array $data): Message $message->writeAccessAllowed = WriteAccessAllowed::fromArray($data['write_access_allowed']); } + if (isset($data['entities']) && $data['entities']) { + /* @phpstan-ignore-next-line */ + $message->entities = collect($data['entities'])->map(fn (array $entity) => Entity::fromArray($entity)); + } + return $message; } @@ -344,6 +354,14 @@ public function writeAccessAllowed(): ?WriteAccessAllowed return $this->writeAccessAllowed; } + /** + * @return Collection + */ + public function entities(): Collection + { + return $this->entities; + } + public function toArray(): array { return array_filter([ @@ -372,6 +390,7 @@ public function toArray(): array 'left_chat_member' => $this->leftChatMember, 'web_app_data' => $this->webAppData, 'write_access_allowed' => $this->writeAccessAllowed?->toArray(), + 'entities' => $this->entities->toArray(), ], fn ($value) => $value !== null); } } diff --git a/src/DTO/Reaction.php b/src/DTO/Reaction.php new file mode 100644 index 00000000..12740f05 --- /dev/null +++ b/src/DTO/Reaction.php @@ -0,0 +1,136 @@ +> + */ +class Reaction implements Arrayable +{ + private int $id; + + private Chat $chat; + private ?Chat $actorChat = null; + + private ?User $from = null; + + /** + * @var Collection + */ + private Collection $oldReaction; + + /** + * @var Collection + */ + private Collection $newReaction; + + private CarbonInterface $date; + + private function __construct() + { + $this->oldReaction = Collection::empty(); + $this->newReaction = Collection::empty(); + } + + /** + * @param array{ + * message_id: int, + * chat: array, + * actor_chat?: array, + * user?: array, + * date: int, + * old_reaction: array>, + * new_reaction: array> + * } $data + */ + public static function fromArray(array $data): Reaction + { + $reaction = new self(); + + $reaction->id = $data['message_id']; + + /* @phpstan-ignore-next-line */ + $reaction->chat = Chat::fromArray($data['chat']); + + if (isset($data['actor_chat'])) { + /* @phpstan-ignore-next-line */ + $reaction->actorChat = Chat::fromArray($data['actor_chat']); + } + + if (isset($data['user'])) { + /* @phpstan-ignore-next-line */ + $reaction->from = User::fromArray($data['user']); + } + + $reaction->date = Carbon::createFromTimestamp($data['date']); + + /* @phpstan-ignore-next-line */ + $reaction->oldReaction = collect($data['old_reaction'] ?? [])->map(fn (array $reactionData) => ReactionType::fromArray($reactionData)); + + /* @phpstan-ignore-next-line */ + $reaction->newReaction = collect($data['new_reaction'] ?? [])->map(fn (array $reactionData) => ReactionType::fromArray($reactionData)); + + return $reaction; + } + + public function id(): int + { + return $this->id; + } + + public function chat(): Chat + { + return $this->chat; + } + + public function actorChat(): ?Chat + { + return $this->actorChat; + } + + public function from(): ?User + { + return $this->from; + } + + /** + * @return Collection + */ + public function oldReaction(): Collection + { + return $this->oldReaction; + } + + /** + * @return Collection + */ + public function newReaction(): Collection + { + return $this->newReaction; + } + + public function date(): CarbonInterface + { + return $this->date; + } + + public function toArray(): array + { + return array_filter([ + 'id' => $this->id, + 'chat' => $this->chat->toArray(), + 'actor_chat' => $this->actorChat?->toArray(), + 'from' => $this->from?->toArray(), + 'old_reaction' => $this->oldReaction->toArray(), + 'new_reaction' => $this->newReaction->toArray(), + 'date' => $this->date->toISOString(), + ], fn ($value) => $value !== null); + } +} diff --git a/src/DTO/ReactionType.php b/src/DTO/ReactionType.php new file mode 100644 index 00000000..39203fae --- /dev/null +++ b/src/DTO/ReactionType.php @@ -0,0 +1,67 @@ + + */ +class ReactionType implements Arrayable +{ + public const TYPE_EMOJI = 'emoji'; + public const TYPE_CUSTOM_EMOJI = 'custom_emoji'; + public const TYPE_PAID_EMOJI = 'paid'; + + private string $type; + private string $emoji; + private ?string $customEmojiId = null; + + private function __construct() + { + } + + /** + * @param array{ + * type: string, + * emoji: string, + * custom_emoji_id?: string + * } $data + */ + public static function fromArray(array $data): ReactionType + { + $reaction = new self(); + + $reaction->type = $data['type']; + $reaction->emoji = $data['emoji']; + $reaction->customEmojiId = $data['custom_emoji_id'] ?? null; + + return $reaction; + } + + public function type(): string + { + return $this->type; + } + + public function emoji(): string + { + return $this->emoji; + } + + public function customEmojiId(): ?string + { + return $this->customEmojiId; + } + + public function toArray(): array + { + return array_filter([ + 'type' => $this->type, + 'emoji' => $this->emoji, + 'custom_emoji_id' => $this->customEmojiId, + ], fn ($value) => $value !== null); + } +} diff --git a/src/DTO/TelegramUpdate.php b/src/DTO/TelegramUpdate.php index c3cdd6b6..4c98e077 100644 --- a/src/DTO/TelegramUpdate.php +++ b/src/DTO/TelegramUpdate.php @@ -15,6 +15,7 @@ class TelegramUpdate implements Arrayable { private int $id; private ?Message $message = null; + private ?Reaction $messageReaction = null; private ?CallbackQuery $callbackQuery = null; private ?ChatMemberUpdate $botChatStatusChange = null; private ?InlineQuery $inlineQuery = null; @@ -28,6 +29,7 @@ private function __construct() * update_id:int, * message?:array, * edited_message?:array, + * message_reaction?:array, * channel_post?:array, * callback_query?:array, * my_chat_member?:array, @@ -50,6 +52,11 @@ public static function fromArray(array $data): TelegramUpdate $update->message = Message::fromArray($data['edited_message']); } + if (isset($data['message_reaction'])) { + /* @phpstan-ignore-next-line */ + $update->messageReaction = Reaction::fromArray($data['message_reaction']); + } + if (isset($data['channel_post'])) { /* @phpstan-ignore-next-line */ $update->message = Message::fromArray($data['channel_post']); @@ -83,6 +90,11 @@ public function message(): ?Message return $this->message; } + public function messageReaction(): ?Reaction + { + return $this->messageReaction; + } + public function callbackQuery(): ?CallbackQuery { return $this->callbackQuery; @@ -103,6 +115,7 @@ public function toArray(): array return array_filter([ 'id' => $this->id, 'message' => $this->message?->toArray(), + 'message_reaction' => $this->messageReaction?->toArray(), 'callback_query' => $this->callbackQuery?->toArray(), 'bot_chat_status_change' => $this->botChatStatusChange?->toArray(), 'inline_query' => $this->inlineQuery?->toArray(), diff --git a/src/Facades/Telegraph.php b/src/Facades/Telegraph.php index 2e662de3..f42cfb21 100644 --- a/src/Facades/Telegraph.php +++ b/src/Facades/Telegraph.php @@ -58,6 +58,9 @@ * @method static \DefStudio\Telegraph\Telegraph setTitle(string $title) * @method static \DefStudio\Telegraph\Telegraph setDescription(string $description) * @method static \DefStudio\Telegraph\Telegraph setChatPhoto(string $path) + * @method static \DefStudio\Telegraph\Telegraph setMessageReaction(int $message_id, array $reaction, bool $isBig = false) + * @method static \DefStudio\Telegraph\Telegraph reactWithEmoji(int $message_id, string $emoji, bool $isBig = false) + * @method static \DefStudio\Telegraph\Telegraph reactWithCustomEmoji(int $message_id, string $customEmoji, bool $isBig = false) * @method static \DefStudio\Telegraph\Telegraph chatInfo() * @method static \DefStudio\Telegraph\Telegraph generateChatPrimaryInviteLink() * @method static \DefStudio\Telegraph\Telegraph createChatInviteLink() @@ -87,6 +90,7 @@ * @method static void assertSentData(string $endpoint, array $data = null, bool $exact = true) * @method static void assertSentFiles(string $endpoint, array $files = null) * @method static void assertSent(string $message, bool $exact = true) + * @method static void assertNotSent(string $message, bool $exact = true) * @method static void assertNothingSent() * @method static void assertRegisteredWebhook(array $data = null, bool $exact = true) * @method static void assertUnregisteredWebhook(array $data = null, bool $exact = true) diff --git a/src/Handlers/WebhookHandler.php b/src/Handlers/WebhookHandler.php index 7c8d8670..6b6e2005 100644 --- a/src/Handlers/WebhookHandler.php +++ b/src/Handlers/WebhookHandler.php @@ -12,6 +12,7 @@ use DefStudio\Telegraph\DTO\Chat; use DefStudio\Telegraph\DTO\InlineQuery; use DefStudio\Telegraph\DTO\Message; +use DefStudio\Telegraph\DTO\Reaction; use DefStudio\Telegraph\DTO\User; use DefStudio\Telegraph\Exceptions\TelegramWebhookException; use DefStudio\Telegraph\Keyboard\Keyboard; @@ -37,10 +38,11 @@ abstract class WebhookHandler protected Request $request; protected Message|null $message = null; + protected Reaction|null $reaction = null; protected CallbackQuery|null $callbackQuery = null; /** - * @var Collection + * @var Collection|Collection> */ protected Collection $data; @@ -51,7 +53,7 @@ public function __construct() $this->originalKeyboard = Keyboard::make(); } - private function handleCallbackQuery(): void + protected function handleCallbackQuery(): void { $this->extractCallbackQueryData(); @@ -73,10 +75,9 @@ private function handleCallbackQuery(): void App::call([$this, $action], $this->data->toArray()); } - private function handleCommand(Stringable $text): void + protected function handleCommand(Stringable $text): void { - $command = (string) $text->after('/')->before(' ')->before('@'); - $parameter = (string) $text->after('@')->after(' '); + [$command, $parameter] = $this->parseCommand($text); if (!$this->canHandle($command)) { $this->handleUnknownCommand($text); @@ -90,17 +91,15 @@ private function handleCommand(Stringable $text): void protected function handleUnknownCommand(Stringable $text): void { if ($this->message?->chat()?->type() === Chat::TYPE_PRIVATE) { - $command = (string) $text->after('/')->before(' ')->before('@'); - if (config('telegraph.report_unknown_webhook_commands', config('telegraph.webhook.report_unknown_commands', true))) { - report(TelegramWebhookException::invalidCommand($command)); + report(TelegramWebhookException::invalidCommand($this->parseCommand($text)[0])); } $this->chat->html(__('telegraph::errors.invalid_command'))->send(); } } - private function handleMessage(): void + protected function handleMessage(): void { $this->extractMessageData(); @@ -110,7 +109,7 @@ private function handleMessage(): void $text = Str::of($this->message?->text() ?? ''); - if ($text->startsWith('/')) { + if ($text->startsWith($this->commandPrefixes())) { $this->handleCommand($text); return; @@ -134,6 +133,18 @@ private function handleMessage(): void $this->handleChatMessage($text); } + protected function handleReaction(): void + { + $this->extractReactionData(); + + if (config('telegraph.debug_mode', config('telegraph.webhook.debug'))) { + Log::debug('Telegraph webhook message', $this->data->toArray()); + } + + /** @phpstan-ignore-next-line */ + $this->handleChatReaction($this->reaction->newReaction(), $this->reaction->oldReaction()); + } + protected function canHandle(string $action): bool { if ($action === 'handle') { @@ -181,6 +192,17 @@ protected function extractMessageData(): void ]); } + protected function extractReactionData(): void + { + $this->setupChat(); + + assert($this->reaction !== null); + + $this->messageId = $this->reaction->id(); + + $this->data = collect($this->reaction->newReaction()); + } + protected function handleChatMemberJoined(User $member): void { // .. do nothing @@ -196,6 +218,17 @@ protected function handleChatMessage(Stringable $text): void // .. do nothing } + /** + * @param Collection $newReactions + * @param Collection $oldReactions + * + * @return void + */ + protected function handleChatReaction(Collection $newReactions, Collection $oldReactions): void + { + // .. do nothing + } + protected function replaceKeyboard(Keyboard $newKeyboard): void { $this->chat->replaceKeyboard($this->messageId, $newKeyboard)->send(); @@ -253,6 +286,14 @@ public function handle(Request $request, TelegraphBot $bot): void return; } + if ($this->request->has('message_reaction')) { + /* @phpstan-ignore-next-line */ + $this->reaction = Reaction::fromArray($this->request->input('message_reaction')); + $this->handleReaction(); + + return; + } + if ($this->request->has('callback_query')) { /* @phpstan-ignore-next-line */ @@ -278,6 +319,8 @@ protected function setupChat(): void { if (isset($this->message)) { $telegramChat = $this->message->chat(); + } elseif (isset($this->reaction)) { + $telegramChat = $this->reaction->chat(); } else { $telegramChat = $this->callbackQuery?->message()?->chat(); } @@ -296,10 +339,7 @@ protected function setupChat(): void } if (config('telegraph.security.store_unknown_chats_in_db', false)) { - $this->chat->name = Str::of("") - ->append("[", $telegramChat->type(), ']') - ->append(" ", $telegramChat->title()); - $this->chat->save(); + $this->createChat($telegramChat, $this->chat); } } } @@ -307,7 +347,8 @@ protected function setupChat(): void protected function allowUnknownChat(): bool { return (bool) match (true) { - $this->message !== null => config('telegraph.security.allow_messages_from_unknown_chats', false), + $this->message !== null, + $this->reaction !== null => config('telegraph.security.allow_messages_from_unknown_chats', false), $this->callbackQuery != null => config('telegraph.security.allow_callback_queries_from_unknown_chats', false), default => false, }; @@ -323,4 +364,50 @@ protected function onFailure(Throwable $throwable): void rescue(fn () => $this->reply(__('telegraph::errors.webhook_error_occurred')), report: false); } + + /** + * @return string[] + */ + protected function parseCommand(Stringable $text): array + { + $command = $text->before('@')->before(' '); + + foreach ($this->commandPrefixes() as $prefix) { + if ($command->startsWith($prefix)) { + $parameter = $text->after($command)->after('@')->after(' '); + $command = $command->after($prefix); + + break; + } + } + + return [(string) $command, (string) ($parameter ?? '')]; + } + + /** + * @return Collection + */ + protected function commandPrefixes(): Collection + { + /** @var string[] $prefixes */ + $prefixes = config('telegraph.commands.start_with', []); + + return collect($prefixes) + ->push('/') + ->map(fn (string $prefix) => str($prefix)->trim()->toString()) + ->unique(); + } + + protected function createChat(Chat $telegramChat, TelegraphChat $chat): void + { + $chat->name = $this->getChatName($telegramChat); + $chat->save(); + } + + protected function getChatName(Chat $chat): string + { + return Str::of("") + ->append("[", $chat->type(), ']') + ->append(" ", $chat->title()); + } } diff --git a/src/Jobs/SendRequestToTelegramJob.php b/src/Jobs/SendRequestToTelegramJob.php index bb948098..54ea9582 100644 --- a/src/Jobs/SendRequestToTelegramJob.php +++ b/src/Jobs/SendRequestToTelegramJob.php @@ -43,6 +43,6 @@ function ($request, Attachment $attachment, string $key) { ); /** @phpstan-ignore-next-line */ - $request->timeout(config('telegraph.http_timeout', 30))->post($this->url, $this->data); + $request->timeout(config('telegraph.http_timeout', 30))->connectTimeout(config('telegraph.http_connection_timeout', 10))->post($this->url, $this->data); } } diff --git a/src/Models/TelegraphChat.php b/src/Models/TelegraphChat.php index b337237f..868d088b 100644 --- a/src/Models/TelegraphChat.php +++ b/src/Models/TelegraphChat.php @@ -293,6 +293,28 @@ public function setChatPhoto(string $path): Telegraph return TelegraphFacade::chat($this)->setChatPhoto($path); } + /** + * @param array $reaction + */ + public function setMessageReaction(int $messageId, array $reaction, bool $isBig = false): Telegraph + { + return TelegraphFacade::chat($this)->setMessageReaction($messageId, $reaction, $isBig); + } + + public function reactWithEmoji(int $messageId, string $emoji, bool $isBig = false): Telegraph + { + $reaction = ['type' => 'emoji', 'emoji' => $emoji]; + + return $this->setMessageReaction($messageId, $reaction, $isBig); + } + + public function reactWithCustomEmoji(int $messageId, string $customEmoji, bool $isBig = false): Telegraph + { + $reaction = ['type' => 'custom_emoji', 'emoji' => $customEmoji]; + + return $this->setMessageReaction($messageId, $reaction, $isBig); + } + public function deleteChatPhoto(): Telegraph { return TelegraphFacade::chat($this)->deleteChatPhoto(); diff --git a/src/Support/Testing/Fakes/TelegraphFake.php b/src/Support/Testing/Fakes/TelegraphFake.php index bbf19811..62c3b821 100644 --- a/src/Support/Testing/Fakes/TelegraphFake.php +++ b/src/Support/Testing/Fakes/TelegraphFake.php @@ -130,6 +130,13 @@ public function assertSent(string $message, bool $exact = true): void ], $exact); } + public function assertNotSent(string $message, bool $exact = true): void + { + $this->assertNotSentData(Telegraph::ENDPOINT_MESSAGE, [ + 'text' => $message, + ], $exact); + } + public function assertNothingSent(): void { Assert::assertEmpty(self::$sentMessages, sprintf("Failed to assert that no request were sent (sent %d requests so far)", count(self::$sentMessages))); diff --git a/src/Telegraph.php b/src/Telegraph.php index fbf38e03..849ad36b 100755 --- a/src/Telegraph.php +++ b/src/Telegraph.php @@ -83,6 +83,7 @@ class Telegraph public const ENDPOINT_SET_CHAT_TITLE = 'setChatTitle'; public const ENDPOINT_SET_CHAT_DESCRIPTION = 'setChatDescription'; public const ENDPOINT_SET_CHAT_PHOTO = 'setChatPhoto'; + public const ENDPOINT_SET_MESSAGE_REACTION = 'setMessageReaction'; public const ENDPOINT_DELETE_CHAT_PHOTO = 'deleteChatPhoto'; public const ENDPOINT_EXPORT_CHAT_INVITE_LINK = 'exportChatInviteLink'; public const ENDPOINT_CREATE_CHAT_INVITE_LINK = 'createChatInviteLink'; diff --git a/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction.snap b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction.snap new file mode 100644 index 00000000..7a8ceba6 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction.snap @@ -0,0 +1,10 @@ +{ + "url": "https:\/\/api.telegram.org\/bot3f3814e1-5836-3d77-904e-60f64b15df36\/setMessageReaction", + "payload": { + "chat_id": "-123456789", + "message_id": 100, + "reaction": "[{\"type\":\"emoji\",\"emoji\":\"\\ud83d\\udc4d\"}]", + "is_big": false + }, + "files": [] +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_custom_emoji.snap b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_custom_emoji.snap new file mode 100644 index 00000000..815a2fdb --- /dev/null +++ b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_custom_emoji.snap @@ -0,0 +1,10 @@ +{ + "url": "https:\/\/api.telegram.org\/bot3f3814e1-5836-3d77-904e-60f64b15df36\/setMessageReaction", + "payload": { + "chat_id": "-123456789", + "message_id": 100, + "reaction": "[{\"type\":\"custom_emoji\",\"emoji\":\"12312\"}]", + "is_big": false + }, + "files": [] +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_emoji.snap b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_emoji.snap new file mode 100644 index 00000000..7a8ceba6 --- /dev/null +++ b/tests/.pest/snapshots/Unit/Concerns/HasBotsAndChatsTest/it_can_change_a_message_reaction_with_emoji.snap @@ -0,0 +1,10 @@ +{ + "url": "https:\/\/api.telegram.org\/bot3f3814e1-5836-3d77-904e-60f64b15df36\/setMessageReaction", + "payload": { + "chat_id": "-123456789", + "message_id": 100, + "reaction": "[{\"type\":\"emoji\",\"emoji\":\"\\ud83d\\udc4d\"}]", + "is_big": false + }, + "files": [] +} \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Models/TelegraphBotTest/it_can_poll_for_updates.snap b/tests/.pest/snapshots/Unit/Models/TelegraphBotTest/it_can_poll_for_updates.snap index 8c2c08cf..222f54a1 100644 --- a/tests/.pest/snapshots/Unit/Models/TelegraphBotTest/it_can_poll_for_updates.snap +++ b/tests/.pest/snapshots/Unit/Models/TelegraphBotTest/it_can_poll_for_updates.snap @@ -21,7 +21,14 @@ "title": "john_smith" }, "photos": [], - "new_chat_members": [] + "new_chat_members": [], + "entities": [ + { + "type": "bot_command", + "offset": 0, + "length": 6 + } + ] } }, { @@ -46,7 +53,8 @@ "title": "Bot Test Chat" }, "photos": [], - "new_chat_members": [] + "new_chat_members": [], + "entities": [] } } ] \ No newline at end of file diff --git a/tests/Pest.php b/tests/Pest.php index ec21517f..5f7822db 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -106,6 +106,34 @@ function webhook_message($handler = TestWebhookHandler::class, array $message = ]); } +function webhook_message_reaction($handler = TestWebhookHandler::class, array $message = null): Request +{ + register_webhook_handler($handler); + + return Request::create('', 'POST', [ + 'message_reaction' => $message ?? [ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'first_name' => 'a', + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [], + ], + ]); +} + function webhook_request($action = 'invalid', $handler = TestWebhookHandler::class, int $chat_id = -123456789): Request { register_webhook_handler($handler); diff --git a/tests/Support/TestEntitiesWebhookHandler.php b/tests/Support/TestEntitiesWebhookHandler.php new file mode 100644 index 00000000..38894f80 --- /dev/null +++ b/tests/Support/TestEntitiesWebhookHandler.php @@ -0,0 +1,25 @@ +message->entities()->first(); + + $fromText = $text->substr($entity->offset(), $entity->length()); + $fromEntity = $entity->url(); + + $this->chat->html(implode('. ', [ + 'URL from text: ' . $fromText, + 'URL from entity: ' . $fromEntity, + ]))->send(); + } +} diff --git a/tests/Support/TestWebhookHandler.php b/tests/Support/TestWebhookHandler.php index 2349f753..a9e9559b 100644 --- a/tests/Support/TestWebhookHandler.php +++ b/tests/Support/TestWebhookHandler.php @@ -13,6 +13,7 @@ use DefStudio\Telegraph\Keyboard\Button; use DefStudio\Telegraph\Keyboard\Keyboard; use Exception; +use Illuminate\Support\Collection; use Illuminate\Support\Stringable; class TestWebhookHandler extends WebhookHandler @@ -140,4 +141,14 @@ protected function handleChatMemberLeft(User $member): void { $this->chat->html("{$member->firstName()} just left")->send(); } + + protected function handleChatReaction(Collection $newReactions, Collection $oldReactions): void + { + $this->chat->html(implode(':', [ + /* @phpstan-ignore-next-line */ + 'New reaction is ' . $newReactions->first()->emoji(), + /* @phpstan-ignore-next-line */ + 'Old reaction is ' . $oldReactions->first()->emoji(), + ]))->send(); + } } diff --git a/tests/Unit/Concerns/HasBotsAndChatsTest.php b/tests/Unit/Concerns/HasBotsAndChatsTest.php index d915e2e1..606d68bf 100644 --- a/tests/Unit/Concerns/HasBotsAndChatsTest.php +++ b/tests/Unit/Concerns/HasBotsAndChatsTest.php @@ -95,6 +95,24 @@ })->toMatchTelegramSnapshot(); }); +it('can change a message reaction', function () { + expect(function (\DefStudio\Telegraph\Telegraph $telegraph) { + return $telegraph->chat(make_chat())->setMessageReaction(100, ['type' => 'emoji', 'emoji' => '👍']); + })->toMatchTelegramSnapshot(); +}); + +it('can change a message reaction with emoji', function () { + expect(function (\DefStudio\Telegraph\Telegraph $telegraph) { + return $telegraph->chat(make_chat())->reactWithEmoji(100, '👍'); + })->toMatchTelegramSnapshot(); +}); + +it('can change a message reaction with custom emoji', function () { + expect(function (\DefStudio\Telegraph\Telegraph $telegraph) { + return $telegraph->chat(make_chat())->reactWithCustomEmoji(100, '12312'); + })->toMatchTelegramSnapshot(); +}); + test('chat description cannot overflow 255 chars', function () { Telegraph::chat(make_chat())->setDescription(str_repeat('a', 256)); })->throws(ChatSettingsException::class, "Telegram Chat description max length (255) exceeded"); diff --git a/tests/Unit/DTO/EntityTest.php b/tests/Unit/DTO/EntityTest.php new file mode 100644 index 00000000..d540fd66 --- /dev/null +++ b/tests/Unit/DTO/EntityTest.php @@ -0,0 +1,40 @@ + 2, + 'date' => now()->timestamp, + 'entities' => [ + [ + 'type' => 'url', + 'offset' => 10, + 'length' => 19, + 'url' => 'https://example.com', + 'user' => [ + 'id' => 1, + 'is_bot' => true, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'language' => 'en', + 'custom_emoji_id' => '12345', + ], + ], + ]); + + $array = $dto->entities()->first()->toArray(); + + $reflection = new ReflectionClass(Entity::class); + foreach ($reflection->getProperties() as $property) { + expect($array)->toHaveKey(Str::of($property->name)->snake()); + } +}); diff --git a/tests/Unit/DTO/MessageTest.php b/tests/Unit/DTO/MessageTest.php index 760d4325..647282e5 100644 --- a/tests/Unit/DTO/MessageTest.php +++ b/tests/Unit/DTO/MessageTest.php @@ -296,6 +296,14 @@ "web_app_name" => "test", "from_attachment_menu" => true, ], + 'entities' => [ + [ + 'type' => 'url', + 'offset' => 4, + 'length' => 19, + 'url' => 'https://example.com', + ], + ], ]); $array = $dto->toArray(); diff --git a/tests/Unit/DTO/ReactionTest.php b/tests/Unit/DTO/ReactionTest.php new file mode 100644 index 00000000..bfaa0427 --- /dev/null +++ b/tests/Unit/DTO/ReactionTest.php @@ -0,0 +1,260 @@ + [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ], + ]); + + $array = $dto->toArray(); + + $reflection = new ReflectionClass($dto); + foreach ($reflection->getProperties() as $property) { + expect($array)->toHaveKey(Str::of($property->name)->snake()); + } +}); + +it('extract chat info', function () { + $dto = Reaction::fromArray([ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [], + ]); + + expect($dto->chat()) + ->toBeInstanceOf(Chat::class) + ->id()->toBe('3') + ->type()->toBe('a') + ->title()->toBe('b'); +}); + +it('extract actor chat info', function () { + $dto = Reaction::fromArray([ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [], + ]); + + expect($dto->actorChat()) + ->toBeInstanceOf(Chat::class) + ->id()->toBe('3') + ->type()->toBe('a') + ->title()->toBe('b'); +}); + +it('extract from info', function () { + $dto = Reaction::fromArray([ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [], + ]); + + expect($dto->from()) + ->toBeInstanceOf(User::class) + ->id()->toBe(1) + ->firstName()->toBe('a') + ->lastName()->toBe('b'); +}); + +it('extract old_reaction info', function () { + $dto = Reaction::fromArray([ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ], + ]); + + expect($dto->oldReaction()->toArray())->toBe([ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ]); +}); + +it('extract new_reaction info', function () { + $dto = Reaction::fromArray([ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ], + ]); + + expect($dto->newReaction()->toArray())->toBe([ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ]); +}); diff --git a/tests/Unit/DTO/TelegramUpdateTest.php b/tests/Unit/DTO/TelegramUpdateTest.php index 3c83f4ba..35661595 100644 --- a/tests/Unit/DTO/TelegramUpdateTest.php +++ b/tests/Unit/DTO/TelegramUpdateTest.php @@ -18,6 +18,39 @@ 'date' => now()->timestamp, 'text' => 'f', ], + 'message_reaction' => [ + 'message_id' => 2, + 'date' => now()->timestamp, + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'user' => [ + 'id' => 1, + 'is_bot' => true, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + ], + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ], + ], 'channel_post' => [ 'message_id' => 4, 'date' => now()->timestamp, diff --git a/tests/Unit/Handlers/WebhookHandlerTest.php b/tests/Unit/Handlers/WebhookHandlerTest.php index b614983e..69ad8f6c 100644 --- a/tests/Unit/Handlers/WebhookHandlerTest.php +++ b/tests/Unit/Handlers/WebhookHandlerTest.php @@ -5,6 +5,7 @@ use DefStudio\Telegraph\Facades\Telegraph as Facade; use DefStudio\Telegraph\Telegraph; +use DefStudio\Telegraph\Tests\Support\TestEntitiesWebhookHandler; use DefStudio\Telegraph\Tests\Support\TestWebhookHandler; use Illuminate\Support\Facades\Config; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -212,6 +213,58 @@ Facade::assertSent("Hello!! your parameter is [foo bot]"); }); +it('can handle a command with custom start char', function () { + Config::set('telegraph.commands.start_with', ['-', '=', '!', ' % ', 1, ' :: ']); + + $bot = bot(); + Facade::fake(); + + app(TestWebhookHandler::class)->handle(webhook_command('/hello@bot foo bot /'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('-hello@bot foo bot -'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('=hello@bot foo bot ='), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('!hello@bot foo bot !'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('%hello@bot foo bot %'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('1hello@bot foo bot 1'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('::hello@bot foo bot : :'), $bot); + + Facade::assertSent("Hello!! your parameter is [foo bot /]"); + Facade::assertSent("Hello!! your parameter is [foo bot -]"); + Facade::assertSent("Hello!! your parameter is [foo bot =]"); + Facade::assertSent("Hello!! your parameter is [foo bot !]"); + Facade::assertSent("Hello!! your parameter is [foo bot %]"); + Facade::assertSent("Hello!! your parameter is [foo bot 1]"); + Facade::assertSent("Hello!! your parameter is [foo bot : :]"); +}); + +it('can handle a command without parameter', function () { + $bot = bot(); + Facade::fake(); + + app(TestWebhookHandler::class)->handle(webhook_command('/hello'), $bot); + + Facade::assertSent("Hello!!"); +}); + +it('cannot handle a command with custom start char', function () { + $bot = bot(); + Facade::fake(); + + app(TestWebhookHandler::class)->handle(webhook_command('/hello@bot foo bot /'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('-hello@bot foo bot -'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('=hello@bot foo bot ='), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('!hello@bot foo bot !'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('%hello@bot foo bot %'), $bot); + app(TestWebhookHandler::class)->handle(webhook_command('1hello@bot foo bot 1'), $bot); + + Facade::assertSent("Hello!! your parameter is [foo bot /]"); + + Facade::assertNotSent("Hello!! your parameter is [foo bot -]"); + Facade::assertNotSent("Hello!! your parameter is [foo bot =]"); + Facade::assertNotSent("Hello!! your parameter is [foo bot !]"); + Facade::assertNotSent("Hello!! your parameter is [foo bot %]"); + Facade::assertNotSent("Hello!! your parameter is [foo bot 1]"); +}); + it('can change the inline keyboard', function () { Config::set('telegraph.security.allow_callback_queries_from_unknown_chats', true); Config::set('telegraph.security.allow_messages_from_unknown_chats', true); @@ -357,6 +410,83 @@ Facade::assertSent("Bob just left"); }); +it('can handle a message reaction', function () { + Config::set('telegraph.security.allow_messages_from_unknown_chats', true); + + $bot = bot(); + Facade::fake(); + + app(TestWebhookHandler::class)->handle(webhook_message_reaction(message: [ + 'chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'actor_chat' => [ + 'id' => 3, + 'type' => 'a', + 'title' => 'b', + ], + 'date' => 1727211008, + 'user' => [ + 'id' => 1, + 'is_bot' => false, + 'first_name' => 'a', + 'last_name' => 'b', + 'username' => 'c', + 'language_code' => 'd', + 'is_premium' => false, + ], + 'message_id' => 2, + 'new_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '👍', + ], + ], + 'old_reaction' => [ + [ + 'type' => 'emoji', + 'emoji' => '🔥', + ], + ], + ]), $bot); + + Facade::assertSent(implode(':', [ + 'New reaction is 👍', + 'Old reaction is 🔥', + ])); +}); + +it('can handle a message entities', function () { + $bot = bot(); + Facade::fake(); + + app(TestEntitiesWebhookHandler::class)->handle(webhook_message(TestEntitiesWebhookHandler::class, [ + 'message_id' => 123456, + 'chat' => [ + 'id' => -123456789, + 'type' => 'group', + 'title' => 'Test chat', + ], + 'date' => 1646516736, + 'text' => 'foo https://example.com bar', + 'entities' => [ + [ + 'type' => 'url', + 'offset' => 4, + 'length' => 19, + 'url' => 'https://example.com', + ], + ], + ]), $bot); + + Facade::assertSent(implode('. ', [ + 'URL from text: https://example.com', + 'URL from entity: https://example.com', + ])); +}); + it('does not crash on errors', function () { $chat = chat(); diff --git a/tests/Unit/Models/TelegraphChatTest.php b/tests/Unit/Models/TelegraphChatTest.php index de75badf..0aeec337 100644 --- a/tests/Unit/Models/TelegraphChatTest.php +++ b/tests/Unit/Models/TelegraphChatTest.php @@ -904,3 +904,17 @@ 'user_id' => 123456, ], false); }); + +it('can react on a message', function () { + Telegraph::fake(); + $chat = make_chat(); + $reaction = ['type' => 'emoji', 'emoji' => '👍']; + + $chat->setMessageReaction(42, $reaction, false)->send(); + + Telegraph::assertSentData(\DefStudio\Telegraph\Telegraph::ENDPOINT_SET_MESSAGE_REACTION, [ + 'message_id' => 42, + 'reaction' => json_encode([$reaction]), + 'is_big' => false, + ]); +});