diff --git a/docs/12.features/9.dto.md b/docs/12.features/9.dto.md index bb67bb6a..4be1d71c 100644 --- a/docs/12.features/9.dto.md +++ b/docs/12.features/9.dto.md @@ -42,6 +42,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 - `->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 - `->webAppData()` (optional) incoming data from sendData method of telegram WebApp @@ -148,6 +149,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/src/DTO/Entity.php b/src/DTO/Entity.php new file mode 100644 index 00000000..99251703 --- /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 = $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 2df77a6e..da88eb3c 100644 --- a/src/DTO/Message.php +++ b/src/DTO/Message.php @@ -53,9 +53,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(); } /** @@ -85,6 +89,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 @@ -202,6 +207,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; } @@ -331,6 +341,11 @@ public function writeAccessAllowed(): ?WriteAccessAllowed return $this->writeAccessAllowed; } + public function entities(): Collection + { + return $this->entities; + } + public function toArray(): array { return array_filter([ @@ -358,6 +373,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/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/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/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 202760f6..defe1b44 100644 --- a/tests/Unit/DTO/MessageTest.php +++ b/tests/Unit/DTO/MessageTest.php @@ -289,6 +289,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/Handlers/WebhookHandlerTest.php b/tests/Unit/Handlers/WebhookHandlerTest.php index e8479406..5b0f5083 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; @@ -454,6 +455,35 @@ Facade::assertSent("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();