Repository: def-studio/telegraph (php)

Issue:
Handle all type of webhook messages updates (medias)
Hello, thanks for this amazing package. 
Do you plan to add support for any other messages (medias) retrieving by the bot throughout Webhook?
We can actually get only text message

Thanks

Pull request description:
Feat: handle more webhook message types
closes #71 

Patch:
diff --git a/docs/content/en/webhooks/dto.md b/docs/content/en/webhooks/dto.md
index 2c35364d..063dc7ec 100644
--- a/docs/content/en/webhooks/dto.md
+++ b/docs/content/en/webhooks/dto.md
@@ -1,13 +1,17 @@
 ---
-title: 'TelegraphUpdate'
+title: 'Incoming Data'
 description: ''
 category: 'Webhooks'
 fullscreen: false 
 position: 67
 ---
 
+Data obtained from manual polling or webhooks is available through a set of DTO:
+
 ## `DefStudio\Telegraph\DTO\TelegramUpdate`
 
+contains incoming data (a message or a callback query)
+
 - `->id()` incoming _update_id_
 - `->message()` (optional) an instance of [`DefStudio\Telegraph\DTO\Message`](webhooks/dto#defstudio-telegraph-dto-message) 
 - `->callbackQuery()` (optional) an instance of [`DefStudio\Telegraph\DTO\CallbackQuery`](webhooks/dto#defstudio-telegraph-dto-callback-query)
@@ -17,10 +21,18 @@ position: 67
 
 - `->id()` incoming _message_id_
 - `->date()` a `CarbonInterface` holding the message sent date
+- `->editDate()` a `CarbonInterface` holding the message last edit date
 - `->text()` the message text
+- `->protected()` a boolean flag that states the message is protected from forwarding and download
 - `->from()` (optional) an instance of [`DefStudio\Telegraph\DTO\User`](webhooks/dto#defstudio-telegraph-dto-user) holding data about the message's sender
+- `->forwardedFrom()` (optional) an instance of [`DefStudio\Telegraph\DTO\User`](webhooks/dto#defstudio-telegraph-dto-user) holding data about a forwarded message's original sender
 - `->chat()` (optional) an instance of [`DefStudio\Telegraph\DTO\Chat`](webhooks/dto#defstudio-telegraph-dto-chat) holding data about the chat to which the message belongs to 
 - `->keyboard()` (optional) an instance of [`DefStudio\Telegraph\Keyboard\Keyboard`](feature/keyboards) holding the message inline keyboard 
+- `->photos()` (optional) a collection of [`DefStudio\Telegraph\DTO\Photo`](webhooks/dto#defstudio-telegraph-dto-photo) holding data about the contained image resolutions
+- `->audio()` (optional) an instance of [`DefStudio\Telegraph\DTO\Audio`](webhooks/dto#defstudio-telegraph-dto-photo) holding data about the contained audio
+- `->document()` (optional) an instance of [`DefStudio\Telegraph\DTO\Document`](webhooks/dto#defstudio-telegraph-dto-photo) holding data about the contained document
+- `->video()` (optional) an instance of [`DefStudio\Telegraph\DTO\Video`](webhooks/dto#defstudio-telegraph-dto-photo) holding data about the contained video
+- `->location()` (optional) an instance of [`DefStudio\Telegraph\DTO\Location`](webhooks/dto#defstudio-telegraph-dto-photo) holding data about the contained location
 
 
 ## `DefStudio\Telegraph\DTO\CallbackQuery`
@@ -38,3 +50,45 @@ position: 67
 - `->firstName()` user's first name 
 - `->lastName()` user's last name 
 - `->userName()` user's username 
+
+## `DefStudio\Telegraph\DTO\Audio`
+
+- `->id()` file ID
+- `->duration()` audio duration
+- `->title()` (optional) audio title
+- `->filename()` (optional) audio file name
+- `->mimeType()` (optional) audio MIME type
+- `->filesize()` (optional) audio file size in Bytes
+- `->thumbnail()` (optional) an instance of the [`DefStudio\Telegraph\DTO\Photo`](webhooks/dto#defstudio-telegraph-dto-photo) that holds data about the thumbnail
+
+## `DefStudio\Telegraph\DTO\Document`
+
+- `->id()` file ID
+- `->filename()` (optional) document file name
+- `->mimeType()` (optional) document MIME type
+- `->filesize()` (optional) document file size in Bytes
+- `->thumbnail()` (optional) an instance of the [`DefStudio\Telegraph\DTO\Photo`](webhooks/dto#defstudio-telegraph-dto-photo) that holds data about the thumbnail
+
+## `DefStudio\Telegraph\DTO\Photo`
+
+- `->id()` file ID
+- `->width()` photo width
+- `->height()` photo height
+- `->filesize()` (optional) photo file size in Bytes
+
+## `DefStudio\Telegraph\DTO\Video`
+
+- `->id()` file ID
+- `->width()` video width
+- `->height()` video height
+- `->duration()` video duration
+- `->filename()` (optional) video file name
+- `->mimeType()` (optional) video MIME type
+- `->filesize()` (optional) video file size in Bytes
+- `->thumbnail()` (optional) an instance of the [`DefStudio\Telegraph\DTO\Photo`](webhooks/dto#defstudio-telegraph-dto-photo) that holds data about the thumbnail
+
+## `DefStudio\Telegraph\DTO\Location`
+
+- `->latitude()` location latitude
+- `->longitude()` location longitude
+- `->accuracy()` (optional) location horizontal accuracy
diff --git a/src/Concerns/StoresFiles.php b/src/Concerns/StoresFiles.php
new file mode 100644
index 00000000..c1a3c5a5
--- /dev/null
+++ b/src/Concerns/StoresFiles.php
@@ -0,0 +1,55 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+namespace DefStudio\Telegraph\Concerns;
+
+use DefStudio\Telegraph\Contracts\Downloadable;
+use DefStudio\Telegraph\Exceptions\FileException;
+use DefStudio\Telegraph\Telegraph;
+use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Str;
+
+trait StoresFiles
+{
+    public function getFileInfo(string $file_id): Telegraph
+    {
+        $telegraph = clone $this;
+
+        $telegraph->endpoint = self::ENDPOINT_GET_FILE;
+        $telegraph->data['file_id'] = $file_id;
+
+        return $telegraph;
+    }
+
+    public function store(Downloadable|string $downloadable, string $path, string $filename = null): string
+    {
+        $fileId = is_string($downloadable) ? $downloadable : $downloadable->id();
+
+        $response = $this->getFileInfo($fileId)->send();
+
+        if ($response->telegraphError()) {
+            throw FileException::failedToRetreiveFileInfo($fileId);
+        }
+
+        $filePath = $response->json('result.file_path');
+
+        $url = Str::of(self::TELEGRAM_API_FILE_BASE_URL)
+            ->append($this->getBot()->token)
+            ->append('/', $filePath);
+
+        $response = Http::get($url);
+
+        if ($response->failed()) {
+            throw FileException::unableToDownloadFile($fileId);
+        }
+
+        $filename ??= $url->afterLast('/')->before('?');
+
+        File::ensureDirectoryExists($path);
+        File::put($path . "/" . $filename, $response->body());
+
+        return $path . "/" . $filename;
+    }
+}
diff --git a/src/Contracts/Downloadable.php b/src/Contracts/Downloadable.php
new file mode 100644
index 00000000..da9083a2
--- /dev/null
+++ b/src/Contracts/Downloadable.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace DefStudio\Telegraph\Contracts;
+
+interface Downloadable
+{
+    public function id(): string;
+}
diff --git a/src/DTO/Audio.php b/src/DTO/Audio.php
new file mode 100644
index 00000000..a29270bf
--- /dev/null
+++ b/src/DTO/Audio.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace DefStudio\Telegraph\DTO;
+
+use DefStudio\Telegraph\Contracts\Downloadable;
+use Illuminate\Contracts\Support\Arrayable;
+
+class Audio implements Arrayable, Downloadable
+{
+    private string $id;
+    private int $duration;
+
+    private ?string $title = null;
+    private ?string $filename = null;
+    private ?string $mimeType = null;
+    private ?int $filesize = null;
+
+    private ?Photo $thumbnail = null;
+
+    private function __construct()
+    {
+    }
+
+    /**
+     * @param array{
+     *     file_id: string,
+     *     duration: int,
+     *     title?: string,
+     *     file_name?: string,
+     *     mime_type?: string,
+     *     file_size?: int,
+     *     thumb?: array<string, mixed>,
+     * } $data
+     */
+    public static function fromArray(array $data): Audio
+    {
+        $audio = new self();
+
+        $audio->id = $data['file_id'];
+        $audio->duration = $data['duration'];
+
+        $audio->title = $data['title'] ?? null;
+        $audio->filename = $data['file_name'] ?? null;
+        $audio->mimeType = $data['mime_type'] ?? null;
+        $audio->filesize = $data['file_size'] ?? null;
+
+        if (isset($data['thumb'])) {
+            /* @phpstan-ignore-next-line  */
+            $audio->thumbnail = Photo::fromArray($data['thumb']);
+        }
+
+        return $audio;
+    }
+
+    public function id(): string
+    {
+        return $this->id;
+    }
+
+    public function duration(): int
+    {
+        return $this->duration;
+    }
+
+    public function title(): ?string
+    {
+        return $this->title;
+    }
+
+    public function filename(): ?string
+    {
+        return $this->filename;
+    }
+
+    public function mimeType(): ?string
+    {
+        return $this->mimeType;
+    }
+
+    public function filesize(): ?int
+    {
+        return $this->filesize;
+    }
+
+    public function toArray(): array
+    {
+        return array_filter([
+            'id' => $this->id,
+            'duration' => $this->duration,
+            'title' => $this->title,
+            'filename' => $this->filename,
+            'mime_type' => $this->mimeType,
+            'filesize' => $this->filesize,
+            'thumbnail' => $this->thumbnail?->toArray(),
+        ]);
+    }
+}
diff --git a/src/DTO/Document.php b/src/DTO/Document.php
new file mode 100644
index 00000000..b44a78de
--- /dev/null
+++ b/src/DTO/Document.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace DefStudio\Telegraph\DTO;
+
+use DefStudio\Telegraph\Contracts\Downloadable;
+use Illuminate\Contracts\Support\Arrayable;
+
+class Document implements Arrayable, Downloadable
+{
+    private string $id;
+
+    private ?string $filename = null;
+    private ?string $mimeType = null;
+    private ?int $filesize = null;
+
+    private ?Photo $thumbnail = null;
+
+    private function __construct()
+    {
+    }
+
+    /**
+     * @param array{
+     *     file_id: string,
+     *     file_name?: string,
+     *     mime_type?: string,
+     *     file_size?: int,
+     *     thumb?: array<string, mixed>,
+     * } $data
+     */
+    public static function fromArray(array $data): Document
+    {
+        $document = new self();
+
+        $document->id = $data['file_id'];
+        $document->filename = $data['file_name'] ?? null;
+        $document->mimeType = $data['mime_type'] ?? null;
+        $document->filesize = $data['file_size'] ?? null;
+
+        if (isset($data['thumb'])) {
+            /* @phpstan-ignore-next-line  */
+            $document->thumbnail = Photo::fromArray($data['thumb']);
+        }
+
+        return $document;
+    }
+
+    public function id(): string
+    {
+        return $this->id;
+    }
+
+    public function filename(): ?string
+    {
+        return $this->filename;
+    }
+
+    public function mimeType(): ?string
+    {
+        return $this->mimeType;
+    }
+
+    public function filesize(): ?int
+    {
+        return $this->filesize;
+    }
+
+    public function toArray(): array
+    {
+        return array_filter([
+            'id' => $this->id,
+            'filename' => $this->filename,
+            'mime_type' => $this->mimeType,
+            'filesize' => $this->filesize,
+            'thumbnail' => $this->thumbnail?->toArray(),
+        ]);
+    }
+}
diff --git a/src/DTO/Location.php b/src/DTO/Location.php
new file mode 100644
index 00000000..647b1526
--- /dev/null
+++ b/src/DTO/Location.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace DefStudio\Telegraph\DTO;
+
+use Illuminate\Contracts\Support\Arrayable;
+
+class Location implements Arrayable
+{
+    private float $latitude;
+    private float $longitude;
+    private ?float $accuracy = null;
+
+    private function __construct()
+    {
+    }
+
+    /**
+     * @param array{
+     *     longitude: float,
+     *     latitude: float,
+     *     horizontal_accuracy?: float
+     * } $data
+     */
+    public static function fromArray(array $data): Location
+    {
+        $location = new self();
+
+        $location->latitude = $data['latitude'];
+        $location->longitude = $data['longitude'];
+        $location->accuracy = $data['horizontal_accuracy'] ?? null;
+
+        return $location;
+    }
+
+    public function latitude(): float
+    {
+        return $this->latitude;
+    }
+
+    public function longitude(): float
+    {
+        return $this->longitude;
+    }
+
+    public function accuracy(): ?float
+    {
+        return $this->accuracy();
+    }
+
+    public function toArray(): array
+    {
+        return array_filter([
+            'latitude' => $this->latitude,
+            'longitude' => $this->longitude,
+            'accuracy' => $this->accuracy,
+        ]);
+    }
+}
diff --git a/src/DTO/Message.php b/src/DTO/Message.php
index 4a9f02c8..4ee697c8 100644
--- a/src/DTO/Message.php
+++ b/src/DTO/Message.php
@@ -1,5 +1,7 @@
 <?php
 
+/** @noinspection PhpUnused */
+
 /** @noinspection PhpDocSignatureIsNotCompleteInspection */
 
 namespace DefStudio\Telegraph\DTO;
@@ -8,23 +10,54 @@ use Carbon\CarbonInterface;
 use DefStudio\Telegraph\Keyboard\Keyboard;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Support\Carbon;
+use Illuminate\Support\Collection;
 
 class Message implements Arrayable
 {
     private int $id;
+
     private CarbonInterface $date;
+    private ?CarbonInterface $editDate = null;
+
     private string $text;
+    private bool $protected = false;
 
     private ?User $from = null;
+    private ?User $forwardedFrom = null;
+
     private ?Chat $chat = null;
     private Keyboard $keyboard;
 
+    /** @var Collection<array-key, Photo> */
+    private Collection $photos;
+
+    private ?Audio $audio = null;
+    private ?Document $document = null;
+    private ?Video $video = null;
+    private ?Location $location = null;
+
     private function __construct()
     {
+        $this->photos = Collection::empty();
     }
 
     /**
-     * @param array{message_id:int, date:int, text?:string, from?:array<string, mixed>, chat?:array<string, mixed>, reply_markup?:array<array<array<string>>>} $data
+     * @param array{
+     *     message_id: int,
+     *     date: int,
+     *     edit_date?: int,
+     *     text?: string,
+     *     has_protected_content?: bool,
+     *     from?: array<string, mixed>,
+     *     forward_from?: array<string, mixed>,
+     *     chat?: array<string, mixed>,
+     *     reply_markup?: array<array<array<string>>>,
+     *     audio?:array<string, mixed>,
+     *     document?: array<string, mixed>,
+     *     video?: array<string, mixed>,
+     *     photo?: array<string, mixed>,
+     *     location?: array<string, mixed>,
+     *  } $data
      */
     public static function fromArray(array $data): Message
     {
@@ -34,13 +67,24 @@ class Message implements Arrayable
 
         $message->date = Carbon::createFromTimestamp($data['date']);
 
+        if (isset($data['edit_date'])) {
+            $message->editDate = Carbon::createFromTimestamp($data['edit_date']);
+        }
+
         $message->text = $data['text'] ?? '';
 
+        $message->protected = $data['has_protected_content'] ?? false;
+
         if (isset($data['from'])) {
             /* @phpstan-ignore-next-line */
             $message->from = User::fromArray($data['from']);
         }
 
+        if (isset($data['forward_from'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->forwardedFrom = User::fromArray($data['forward_from']);
+        }
+
         if (isset($data['chat'])) {
             /* @phpstan-ignore-next-line */
             $message->chat = Chat::fromArray($data['chat']);
@@ -53,6 +97,31 @@ class Message implements Arrayable
             $message->keyboard = Keyboard::make();
         }
 
+        if (isset($data['photo'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->photos = collect($data['photo'])->map(fn (array $photoData) => Photo::fromArray($photoData));
+        }
+
+        if (isset($data['audio'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->audio = Audio::fromArray($data['audio']);
+        }
+
+        if (isset($data['document'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->document = Document::fromArray($data['document']);
+        }
+
+        if (isset($data['video'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->video = Video::fromArray($data['video']);
+        }
+
+        if (isset($data['location'])) {
+            /* @phpstan-ignore-next-line  */
+            $message->location = Location::fromArray($data['location']);
+        }
+
         return $message;
     }
 
@@ -66,16 +135,31 @@ class Message implements Arrayable
         return $this->date;
     }
 
+    public function editDate(): ?CarbonInterface
+    {
+        return $this->editDate;
+    }
+
     public function text(): string
     {
         return $this->text;
     }
 
+    public function protected(): bool
+    {
+        return $this->protected;
+    }
+
     public function from(): ?User
     {
         return $this->from;
     }
 
+    public function forwardFrom(): ?User
+    {
+        return $this->forwardedFrom;
+    }
+
     public function chat(): ?Chat
     {
         return $this->chat;
@@ -86,15 +170,51 @@ class Message implements Arrayable
         return $this->keyboard;
     }
 
+    /**
+     * @return Collection<array-key, Photo>
+     */
+    public function photos(): Collection
+    {
+        return $this->photos;
+    }
+
+    public function audio(): ?Audio
+    {
+        return $this->audio;
+    }
+
+    public function document(): ?Document
+    {
+        return $this->document;
+    }
+
+    public function video(): ?Video
+    {
+        return $this->video;
+    }
+
+    public function location(): ?Location
+    {
+        return $this->location;
+    }
+
     public function toArray(): array
     {
         return array_filter([
-           'id' => $this->id,
-           'date' => $this->date->toISOString(),
-           'text' => $this->text,
-           'from' => $this->from?->toArray(),
-           'chat' => $this->chat?->toArray(),
-           'keyboard' => $this->keyboard->isFilled() ? $this->keyboard->toArray() : null,
+            'id' => $this->id,
+            'date' => $this->date->toISOString(),
+            'edit_date' => $this->editDate?->toISOString(),
+            'text' => $this->text,
+            'protected' => $this->protected,
+            'from' => $this->from?->toArray(),
+            'forwarded_from' => $this->forwardedFrom?->toArray(),
+            'chat' => $this->chat?->toArray(),
+            'keyboard' => $this->keyboard->isFilled() ? $this->keyboard->toArray() : null,
+            'photos' => $this->photos->toArray(),
+            'audio' => $this->audio?->toArray(),
+            'document' => $this->document?->toArray(),
+            'video' => $this->video?->toArray(),
+            'location' => $this->location?->toArray(),
         ]);
     }
 }
diff --git a/src/DTO/Photo.php b/src/DTO/Photo.php
new file mode 100644
index 00000000..068a0c2b
--- /dev/null
+++ b/src/DTO/Photo.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace DefStudio\Telegraph\DTO;
+
+use DefStudio\Telegraph\Contracts\Downloadable;
+use Illuminate\Contracts\Support\Arrayable;
+
+class Photo implements Arrayable, Downloadable
+{
+    private string $id;
+    private int $width;
+    private int $height;
+    private ?int $filesize = null;
+
+    private function __construct()
+    {
+    }
+
+    /**
+     * @param array{
+     *     file_id: string,
+     *     width: int,
+     *     height: int,
+     *     file_size?: int
+     * } $data
+     */
+    public static function fromArray(array $data): Photo
+    {
+        $photo = new self();
+
+        $photo->id = $data['file_id'];
+        $photo->width = $data['width'];
+        $photo->height = $data['height'];
+        $photo->filesize = $data['file_size'] ?? null;
+
+        return $photo;
+    }
+
+    public function id(): string
+    {
+        return $this->id;
+    }
+
+    public function width(): int
+    {
+        return $this->width;
+    }
+
+    public function height(): int
+    {
+        return $this->height;
+    }
+
+    public function filesize(): ?int
+    {
+        return $this->filesize;
+    }
+
+    public function toArray(): array
+    {
+        return array_filter([
+            'id' => $this->id,
+            'width' => $this->width,
+            'height' => $this->height,
+            'filesize' => $this->filesize,
+        ]);
+    }
+}
diff --git a/src/DTO/Video.php b/src/DTO/Video.php
new file mode 100644
index 00000000..40f8e674
--- /dev/null
+++ b/src/DTO/Video.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace DefStudio\Telegraph\DTO;
+
+use DefStudio\Telegraph\Contracts\Downloadable;
+use Illuminate\Contracts\Support\Arrayable;
+
+class Video implements Arrayable, Downloadable
+{
+    private string $id;
+    private int $width;
+    private int $height;
+    private int $duration;
+
+    private ?string $filename = null;
+    private ?string $mimeType = null;
+    private ?int $filesize = null;
+
+    private ?Photo $thumbnail = null;
+
+    private function __construct()
+    {
+    }
+
+    /**
+     * @param array{
+     *     file_id: string,
+     *     width: int,
+     *     height: int,
+     *     duration: int,
+     *     file_name?: string,
+     *     mime_type?: string,
+     *     file_size?: int,
+     *     thumb?: array<string, mixed>,
+     * } $data
+     */
+    public static function fromArray(array $data): Video
+    {
+        $video = new self();
+
+        $video->id = $data['file_id'];
+        $video->width = $data['width'];
+        $video->height = $data['height'];
+        $video->duration = $data['duration'];
+        $video->filename = $data['file_name'] ?? null;
+        $video->mimeType = $data['mime_type'] ?? null;
+        $video->filesize = $data['file_size'] ?? null;
+
+        if (isset($data['thumb'])) {
+            /* @phpstan-ignore-next-line  */
+            $video->thumbnail = Photo::fromArray($data['thumb']);
+        }
+
+        return $video;
+    }
+
+    public function id(): string
+    {
+        return $this->id;
+    }
+
+    public function width(): int
+    {
+        return $this->width;
+    }
+
+    public function height(): int
+    {
+        return $this->height;
+    }
+
+    public function duration(): int
+    {
+        return $this->duration;
+    }
+
+    public function filename(): ?string
+    {
+        return $this->filename;
+    }
+
+    public function mimeType(): ?string
+    {
+        return $this->mimeType;
+    }
+
+    public function filesize(): ?int
+    {
+        return $this->filesize;
+    }
+
+    public function toArray(): array
+    {
+        return array_filter([
+            'id' => $this->id,
+            'width' => $this->width,
+            'height' => $this->height,
+            'duration' => $this->duration,
+            'filename' => $this->filename,
+            'mime_type' => $this->mimeType,
+            'filesize' => $this->filesize,
+            'thumbnail' => $this->thumbnail?->toArray(),
+        ]);
+    }
+}
diff --git a/src/Exceptions/FileException.php b/src/Exceptions/FileException.php
index a295acb9..4d688a29 100644
--- a/src/Exceptions/FileException.php
+++ b/src/Exceptions/FileException.php
@@ -53,4 +53,14 @@ final class FileException extends Exception
 
         return new self(sprintf("Ratio of height and width (%d) exceeds max allowed height of %d",  $relativeRatio, Telegraph::MAX_PHOTO_HEIGHT_WIDTH_RATIO));
     }
+
+    public static function failedToRetreiveFileInfo(string $fileId): FileException
+    {
+        return new self("Failed to retreive info for file [$fileId]");
+    }
+
+    public static function unableToDownloadFile(string $fileId): FileException
+    {
+        return new self("An error occourred while trying to download file [$fileId]");
+    }
 }
diff --git a/src/Facades/Telegraph.php b/src/Facades/Telegraph.php
index 46ab2b8d..95ed0df0 100644
--- a/src/Facades/Telegraph.php
+++ b/src/Facades/Telegraph.php
@@ -2,6 +2,7 @@
 
 namespace DefStudio\Telegraph\Facades;
 
+use DefStudio\Telegraph\Contracts\Downloadable;
 use DefStudio\Telegraph\Keyboard\Keyboard;
 use DefStudio\Telegraph\Models\TelegraphBot;
 use DefStudio\Telegraph\Models\TelegraphChat;
@@ -29,6 +30,7 @@ use Illuminate\Support\Facades\Facade;
  * @method static \DefStudio\Telegraph\Telegraph  location(float $latitude, float $longitude)
  * @method static \DefStudio\Telegraph\Telegraph  botUpdates()
  * @method static \DefStudio\Telegraph\Telegraph  botInfo()
+ * @method static string store(Downloadable $attachment, string $path, string $filename = null)
  * @method static void  dumpSentData()
  * @method static void  assertSentData(string $endpoint, array $data = null)
  * @method static void  assertSentFiles(string $endpoint, array $files = null)
@@ -37,6 +39,7 @@ use Illuminate\Support\Facades\Facade;
  * @method static void  assertRegisteredWebhook()
  * @method static void  assertRequestedWebhookDebugInfo()
  * @method static void  assertRepliedWebhook(string $message)
+ * @method static void  assertStoredFile(string $fileId)
  *
  * @see \DefStudio\Telegraph\Telegraph
  */
diff --git a/src/Models/TelegraphBot.php b/src/Models/TelegraphBot.php
index 00814af3..96f6f003 100644
--- a/src/Models/TelegraphBot.php
+++ b/src/Models/TelegraphBot.php
@@ -7,6 +7,7 @@
 
 namespace DefStudio\Telegraph\Models;
 
+use DefStudio\Telegraph\Contracts\Downloadable;
 use DefStudio\Telegraph\Database\Factories\TelegraphBotFactory;
 use DefStudio\Telegraph\DTO\TelegramUpdate;
 use DefStudio\Telegraph\Exceptions\TelegramUpdatesException;
@@ -127,6 +128,11 @@ class TelegraphBot extends Model
         return $reply->json('result');
     }
 
+    public function store(Downloadable|string $attachment, string $path, string $filename = null): string
+    {
+        return TelegraphFacade::bot($this)->store($attachment, $path, $filename);
+    }
+
     public function url(): string
     {
         return "https://t.me/" . $this->info()['username'];
diff --git a/src/Telegraph.php b/src/Telegraph.php
index 247d3514..c4155ced 100755
--- a/src/Telegraph.php
+++ b/src/Telegraph.php
@@ -14,6 +14,7 @@ use DefStudio\Telegraph\Concerns\InteractsWithTelegram;
 use DefStudio\Telegraph\Concerns\InteractsWithWebhooks;
 use DefStudio\Telegraph\Concerns\ManagesKeyboards;
 use DefStudio\Telegraph\Concerns\SendsAttachments;
+use DefStudio\Telegraph\Concerns\StoresFiles;
 use DefStudio\Telegraph\DTO\Attachment;
 use Illuminate\Foundation\Bus\PendingDispatch;
 use Illuminate\Support\Collection;
@@ -26,6 +27,7 @@ class Telegraph
     use ManagesKeyboards;
     use InteractsWithWebhooks;
     use SendsAttachments;
+    use StoresFiles;
 
     public const MAX_DOCUMENT_SIZE_IN_MB = 50;
     public const MAX_PHOTO_SIZE_IN_MB = 10;
@@ -41,6 +43,7 @@ class Telegraph
     public const PARSE_MARKDOWN = 'markdown';
 
     protected const TELEGRAM_API_BASE_URL = 'https://api.telegram.org/bot';
+    protected const TELEGRAM_API_FILE_BASE_URL = 'https://api.telegram.org/file/bot';
 
     public const ENDPOINT_GET_BOT_UPDATES = 'getUpdates';
     public const ENDPOINT_GET_BOT_INFO = 'getMe';
@@ -57,6 +60,7 @@ class Telegraph
     public const ENDPOINT_SEND_CHAT_ACTION = 'sendChatAction';
     public const ENDPOINT_SEND_DOCUMENT = 'sendDocument';
     public const ENDPOINT_SEND_PHOTO = 'sendPhoto';
+    public const ENDPOINT_GET_FILE = 'getFile';
 
 
     /** @var array<string, mixed> */


Test patch:
diff --git a/docs/content/en/testing/assertions.md b/docs/content/en/testing/assertions.md
index b2cfa689..5c6df0e8 100644
--- a/docs/content/en/testing/assertions.md
+++ b/docs/content/en/testing/assertions.md
@@ -3,7 +3,7 @@ title: 'Fake messages assertions'
 menuTitle: 'Assertions'
 description: ''
 category: 'Testing'
-fullscreen: false 
+fullscreen: false
 position: 71
 ---
 
@@ -49,6 +49,24 @@ Telegraph::assertSentData(
 );
 ```
 
+## assertSentFiles
+
+asserts that the given files were sent to a Telegram API endpoint
+
+```php
+Telegraph::assertSentFiles(\DefStudio\Telegraph\Telegraph::ENDPOINT_SEND_DOCUMENT, [
+   'document' => new Attachment(Storage::path('test.txt'), 'test.txt'),
+]);
+```
+
+## assertStoredFile
+
+asserts that the given incoming file was stored 
+
+```php
+Telegraph::assertStoredFile($fileId);
+```
+
 ## assertRegisteredWebhook
 
 asserts that a webhook register request has been sent
diff --git a/src/Support/Testing/Fakes/TelegraphFake.php b/src/Support/Testing/Fakes/TelegraphFake.php
index e1be2f1c..ed699347 100644
--- a/src/Support/Testing/Fakes/TelegraphFake.php
+++ b/src/Support/Testing/Fakes/TelegraphFake.php
@@ -8,6 +8,7 @@
 
 namespace DefStudio\Telegraph\Support\Testing\Fakes;
 
+use DefStudio\Telegraph\Contracts\Downloadable;
 use DefStudio\Telegraph\DTO\Attachment;
 use DefStudio\Telegraph\Telegraph;
 use GuzzleHttp\Psr7\BufferStream;
@@ -26,6 +27,9 @@ class TelegraphFake extends Telegraph
     /** @var array<int, mixed[]> */
     private static array $sentMessages = [];
 
+    /** @var array<int, string> */
+    private static array $downloadedFiles = [];
+
     /**
      * @param array<string, array<mixed>> $replies
      */
@@ -238,6 +242,15 @@ class TelegraphFake extends Telegraph
         return new Response(new $messageClass($response));
     }
 
+    public function store(Downloadable|string $downloadable, string $path, string $filename = null): string
+    {
+        $fileId = is_string($downloadable) ? $downloadable : $downloadable->id();
+
+        self::$downloadedFiles[] = $fileId;
+
+        return $path . "/" . ($filename ?? 'missing_name.jpg');
+    }
+
     /**
      * @param array<string, string> $data
      */
@@ -319,6 +332,14 @@ class TelegraphFake extends Telegraph
         Assert::assertNotEmpty($foundMessages->toArray(), $errorMessage);
     }
 
+    public function assertStoredFile(string $fileId): void
+    {
+        $downloadedFiles = collect(self::$downloadedFiles)
+            ->filter(fn ($downloadedFileId) => $fileId === $downloadedFileId);
+
+        Assert::assertNotEmpty($downloadedFiles->toArray(), sprintf("Failed to assert that a file with id [%s] was stored (%d files stored so fare)", $fileId, count(self::$downloadedFiles)));
+    }
+
     public function assertSent(string $message, bool $exact = true): void
     {
         $this->assertSentData(Telegraph::ENDPOINT_MESSAGE, [
diff --git a/tests/Sandbox/TelegraphTest.php b/tests/Sandbox/TelegraphTest.php
index fcff5d0a..56f862d1 100644
--- a/tests/Sandbox/TelegraphTest.php
+++ b/tests/Sandbox/TelegraphTest.php
@@ -1,8 +1,12 @@
 <?php
 
+/** @noinspection PhpUnhandledExceptionInspection */
+
 /** @noinspection LaravelFunctionsInspection */
 
+use DefStudio\Telegraph\DTO\Photo;
 use DefStudio\Telegraph\Facades\Telegraph;
+use Illuminate\Support\Facades\Storage;
 use function Spatie\Snapshots\assertMatchesSnapshot;
 
 it('can return bot info', function () {
@@ -11,3 +15,20 @@ it('can return bot info', function () {
     $response = Telegraph::bot($bot)->botInfo()->send();
     assertMatchesSnapshot($response->json('result'));
 })->skip(fn () => empty(env('SANDOBOX_TELEGRAM_BOT_TOKEN')) || env('SANDOBOX_TELEGRAM_BOT_TOKEN') === ':fake_bot_token:', 'Sandbox telegram bot token missing');
+
+it('can store chat files', function () {
+    $bot = sandbox_bot();
+
+    Storage::fake();
+
+    $photo = Photo::fromArray([
+        "file_id" => "AgACAgQAAxkBAAMtYnZXPgRqZddLGQG6LSvtxQ0cQg4AApi5MRtoC7hTdvog5WT4h4QBAAMCAANzAAMkBA",
+        "file_size" => 1514,
+        "width" => 90,
+        "height" => 84,
+    ]);
+
+    Telegraph::bot($bot)->store($photo, Storage::path('images/bot'), 'my_file.jpg');
+
+    expect(Storage::exists('images/bot/my_file.jpg'))->toBeTrue();
+})->skip(fn () => empty(env('SANDOBOX_TELEGRAM_BOT_TOKEN')) || env('SANDOBOX_TELEGRAM_BOT_TOKEN') === ':fake_bot_token:', 'Sandbox telegram bot token missing');
diff --git a/tests/Unit/Concerns/ComposesMessagesTest.php b/tests/Unit/Concerns/ComposesMessagesTest.php
index bed4b22d..a5a9c5a2 100644
--- a/tests/Unit/Concerns/ComposesMessagesTest.php
+++ b/tests/Unit/Concerns/ComposesMessagesTest.php
@@ -3,54 +3,43 @@
 use DefStudio\Telegraph\Telegraph;
 
 it('can send an html message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->html('foobar');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->html('foobar'))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send a markdown message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('foobar');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('foobar'))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send protected content', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('test')->protected();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('test')->protected())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send silent messages', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('test')->silent();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('test')->silent())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can disable url preview', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('test')->withoutPreview();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('test')->withoutPreview())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can reply to a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('test')->reply(123456);
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('test')->reply(123456))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can delete a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->deleteMessage(123456);
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->deleteMessage(123456))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can edit a message', function (callable $setupClosure) {
     expect($setupClosure)->toMatchTelegramSnapshot();
 })->with([
-    'edit before text' => fn () => function (Telegraph $telegraph) {
-        return $telegraph->edit(123456)->markdown('new text');
-    },
-    'edit after text' => fn () => function (Telegraph $telegraph) {
-        return $telegraph->markdown('new text')->edit(123456);
-    },
+    'edit before text' => fn () => fn (Telegraph $telegraph) => $telegraph->edit(123456)->markdown('new text'),
+    'edit after text' => fn () => fn (Telegraph $telegraph) => $telegraph->markdown('new text')->edit(123456),
 ]);
diff --git a/tests/Unit/Concerns/InteractsWithWebhooksTest.php b/tests/Unit/Concerns/InteractsWithWebhooksTest.php
index 859d17ab..2675519d 100644
--- a/tests/Unit/Concerns/InteractsWithWebhooksTest.php
+++ b/tests/Unit/Concerns/InteractsWithWebhooksTest.php
@@ -3,22 +3,16 @@
 use DefStudio\Telegraph\Telegraph;
 
 it('can register a webhook', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->bot(make_bot())
-            ->registerWebhook();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->bot(make_bot())->registerWebhook())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can get webhook debug info', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph ->bot(make_bot())
-            ->getWebhookDebugInfo();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->bot(make_bot())->getWebhookDebugInfo())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can reply to a webhook call', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph ->bot(make_bot())
-            ->replyWebhook(2123456, 'foo');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->bot(make_bot())->replyWebhook(2123456, 'foo'))
+        ->toMatchTelegramSnapshot();
 });
diff --git a/tests/Unit/Concerns/SendsAttachmentsTest.php b/tests/Unit/Concerns/SendsAttachmentsTest.php
index f64708a5..d6df1af7 100644
--- a/tests/Unit/Concerns/SendsAttachmentsTest.php
+++ b/tests/Unit/Concerns/SendsAttachmentsTest.php
@@ -10,9 +10,8 @@ use DefStudio\Telegraph\Telegraph;
 use Illuminate\Support\Facades\Storage;
 
 it('can send a document', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'));
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt')))
+        ->toMatchTelegramSnapshot();
 });
 
 it('requires a chat to send a document', function () {
@@ -20,53 +19,38 @@ it('requires a chat to send a document', function () {
 })->throws(TelegraphException::class, 'No TelegraphChat defined for this request');
 
 it('can attach a document while writing a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('look at **this** file!')
-            ->document(Storage::path('test.txt'));
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('look at **this** file!')->document(Storage::path('test.txt')))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can attach a document with markdown caption', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'))
-            ->markdown('look at **this** file!');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->markdown('look at **this** file!'))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can attach a document with html caption', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'))
-            ->markdown('look at <b>this</b> file!');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->markdown('look at <b>this</b> file!'))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can disable content type detection', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph
-            ->document(Storage::path('test.txt'))
-            ->withoutContentTypeDetection();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->withoutContentTypeDetection())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can disable notification', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'))
-            ->silent();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->silent())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can protect content from sharing', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'))
-            ->protected();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->protected())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send a document replying to a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->document(Storage::path('test.txt'))
-            ->reply(1234);
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->document(Storage::path('test.txt'))->reply(1234))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can attach a keyboard to a document', function () {
@@ -157,15 +141,13 @@ test('thumbnails are validated', function (string $thumbnailPath, bool $valid, s
 ]);
 
 it('can send a location message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->location(12.345, -54.321);
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->location(12.345, -54.321))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send a photo', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'));
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg')))
+        ->toMatchTelegramSnapshot();
 });
 
 it('requires a chat to send a photo', function () {
@@ -173,53 +155,41 @@ it('requires a chat to send a photo', function () {
 })->throws(TelegraphException::class, 'No TelegraphChat defined for this request');
 
 it('can attach a photo while writing a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->markdown('look at **this** file!')
-            ->photo(Storage::path('photo.jpg'));
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->markdown('look at **this** file!')->photo(Storage::path('photo.jpg')))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can attach a photo with markdown caption', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->markdown('look at **this** photo!');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))->markdown('look at **this** photo!'))
+        ->toMatchTelegramSnapshot();
 });
 
 
 it('can attach a photo with html caption', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->markdown('look at <b>this</b> photo!');
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))->markdown('look at <b>this</b> photo!'))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send a photo without notification', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->silent();
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))->silent())
+        ->toMatchTelegramSnapshot();
 });
 
-it('can send a photo protectint it from sharing', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->protected();
-    })->toMatchTelegramSnapshot();
+it('can send a photo protecting it from sharing', function () {
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))->protected())
+        ->toMatchTelegramSnapshot();
 });
 
 it('can send a photo replying to a message', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->reply(1234);
-    })->toMatchTelegramSnapshot();
+    expect(fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))->reply(1234))
+        ->toMatchTelegramSnapshot();
 });
 
 it('can attach a keyboard to a photo', function () {
-    expect(function (Telegraph $telegraph) {
-        return $telegraph->photo(Storage::path('photo.jpg'))
-            ->keyboard(fn (Keyboard $keyboard) => $keyboard->button('def:studio')->url('https://defstudio.it'));
-    })->toMatchTelegramSnapshot();
+    expect(
+        fn (Telegraph $telegraph) => $telegraph->photo(Storage::path('photo.jpg'))
+        ->keyboard(fn (Keyboard $keyboard) => $keyboard->button('def:studio')->url('https://defstudio.it'))
+    )->toMatchTelegramSnapshot();
 });
 
 test('photos are validated', function (string $path, bool $valid, string $exceptionClass = null, string $exceptionMessage = null) {
diff --git a/tests/Unit/Concerns/StoreFilesTest.php b/tests/Unit/Concerns/StoreFilesTest.php
new file mode 100644
index 00000000..a5a9f22f
--- /dev/null
+++ b/tests/Unit/Concerns/StoreFilesTest.php
@@ -0,0 +1,116 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Photo;
+use DefStudio\Telegraph\Exceptions\FileException;
+use DefStudio\Telegraph\Facades\Telegraph as TelegraphFacade;
+use DefStudio\Telegraph\Telegraph;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Storage;
+
+it('can get file info', function () {
+    expect(fn (Telegraph $telegraph) => $telegraph->getFileInfo('123456aaa'))
+        ->toMatchTelegramSnapshot();
+});
+
+it('can store a file by id', function () {
+    Storage::fake();
+
+    Http::fake([
+        "https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile" => [
+            'ok' => true,
+            'result' => [
+                'file_path' => 'photos/file_42.jpg',
+            ],
+        ],
+        "https://api.telegram.org/file/bot3f3814e1-5836-3d77-904e-60f64b15df36/photos/file_42.jpg" => Http::response('test content'),
+    ]);
+
+    TelegraphFacade::bot(bot())->store('123456abc', Storage::path('images/bot'));
+
+    expect(Storage::exists('images/bot/file_42.jpg'))->toBeTrue();
+    expect(Storage::get('images/bot/file_42.jpg'))->toBe('test content');
+});
+
+it('can store a file from a downloadable', function () {
+    Storage::fake();
+
+    Http::fake([
+        "https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile" => [
+            'ok' => true,
+            'result' => [
+                'file_path' => 'photos/file_99.jpg',
+            ],
+        ],
+        "https://api.telegram.org/file/bot3f3814e1-5836-3d77-904e-60f64b15df36/photos/file_99.jpg" => Http::response('test content 2'),
+    ]);
+
+    $downloadable = Photo::fromArray([
+        "file_id" => "123456abc",
+        "file_size" => 1514,
+        "width" => 90,
+        "height" => 84,
+    ]);
+
+    TelegraphFacade::bot(bot())->store($downloadable, Storage::path('images/bot'));
+
+    expect(Storage::exists('images/bot/file_99.jpg'))->toBeTrue();
+    expect(Storage::get('images/bot/file_99.jpg'))->toBe('test content 2');
+});
+
+it('can store a file setting its name', function () {
+    Storage::fake();
+
+
+    Http::fake([
+        "https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile" => [
+            'ok' => true,
+            'result' => [
+                'file_path' => 'photos/file_66.jpg',
+            ],
+        ],
+        "https://api.telegram.org/file/bot3f3814e1-5836-3d77-904e-60f64b15df36/photos/file_66.jpg" => Http::response('test content 3'),
+    ]);
+
+    $downloadable = Photo::fromArray([
+        "file_id" => "123456abc",
+        "file_size" => 1514,
+        "width" => 90,
+        "height" => 84,
+    ]);
+
+    TelegraphFacade::bot(bot())->store($downloadable, Storage::path('images/bot'), 'My Photo.jpg');
+
+    expect(Storage::exists('images/bot/file_66.jpg'))->toBeFalse();
+    expect(Storage::exists('images/bot/My Photo.jpg'))->toBeTrue();
+    expect(Storage::get('images/bot/My Photo.jpg'))->toBe('test content 3');
+});
+
+it('thows an exception if fail to retrieve file info', function () {
+    Http::fake([
+        "https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile" => [
+            'ok' => false,
+            "error_code" => 400,
+            "description" => "Bad Request: invalid file_id",
+        ],
+    ]);
+
+    TelegraphFacade::bot(bot())->store('123456abc', Storage::path('images/bot'), 'My Photo.jpg');
+})->throws(FileException::class, 'Failed to retreive info for file [123456abc]');
+
+it('throws an error if fail to download the file', function () {
+    Storage::fake();
+
+    Http::fake([
+        "https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile" => [
+            'ok' => true,
+            'result' => [
+                'file_path' => 'photos/file_42.jpg',
+            ],
+        ],
+        "https://api.telegram.org/file/bot3f3814e1-5836-3d77-904e-60f64b15df36/photos/file_42.jpg" => Http::response('', 404),
+    ]);
+
+    TelegraphFacade::bot(bot())->store('123456abc', Storage::path('images/bot'));
+})->throws(FileException::class, 'An error occourred while trying to download file [123456abc]');
diff --git a/tests/Unit/DTO/AudioTest.php b/tests/Unit/DTO/AudioTest.php
new file mode 100644
index 00000000..4d75253f
--- /dev/null
+++ b/tests/Unit/DTO/AudioTest.php
@@ -0,0 +1,30 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Audio;
+use Illuminate\Support\Str;
+
+it('export all properties to array', function () {
+    $dto = Audio::fromArray([
+        'file_id' => 31,
+        'duration' => 666,
+        'title' => 'my audio',
+        'file_name' => 'My Audio.mp3',
+        'mime_type' => 'audio/mp3',
+        'file_size' => 42,
+        'thumb' => [
+            'file_id' => 99,
+            'width' => 1024,
+            'height' => 768,
+            'file_size' => 42,
+        ],
+    ]);
+
+    $array = $dto->toArray();
+
+    $reflection = new ReflectionClass($dto);
+    foreach ($reflection->getProperties() as $property) {
+        expect($array)->toHaveKey(Str::of($property->name)->snake());
+    }
+});
diff --git a/tests/Unit/DTO/DocumentTest.php b/tests/Unit/DTO/DocumentTest.php
new file mode 100644
index 00000000..96a59946
--- /dev/null
+++ b/tests/Unit/DTO/DocumentTest.php
@@ -0,0 +1,28 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Document;
+use Illuminate\Support\Str;
+
+it('export all properties to array', function () {
+    $dto = Document::fromArray([
+        'file_id' => 45,
+        'file_name' => 'My Document.pdf',
+        'mime_type' => 'application.pdf',
+        'file_size' => 42,
+        'thumb' => [
+            'file_id' => 99,
+            'width' => 1024,
+            'height' => 768,
+            'file_size' => 42,
+        ],
+    ]);
+
+    $array = $dto->toArray();
+
+    $reflection = new ReflectionClass($dto);
+    foreach ($reflection->getProperties() as $property) {
+        expect($array)->toHaveKey(Str::of($property->name)->snake());
+    }
+});
diff --git a/tests/Unit/DTO/LocationTest.php b/tests/Unit/DTO/LocationTest.php
new file mode 100644
index 00000000..1739c52f
--- /dev/null
+++ b/tests/Unit/DTO/LocationTest.php
@@ -0,0 +1,21 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Location;
+use Illuminate\Support\Str;
+
+it('export all properties to array', function () {
+    $dto = Location::fromArray([
+        'latitude' => 12456789,
+        'longitude' => 98765431,
+        'horizontal_accuracy' => 3,
+    ]);
+
+    $array = $dto->toArray();
+
+    $reflection = new ReflectionClass($dto);
+    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 b2d96b77..6682f58c 100644
--- a/tests/Unit/DTO/MessageTest.php
+++ b/tests/Unit/DTO/MessageTest.php
@@ -10,7 +10,9 @@ it('export all properties to array', function () {
     $dto = Message::fromArray([
         'message_id' => 2,
         'date' => now()->timestamp,
+        'edit_date' => now()->timestamp,
         'text' => 'f',
+        'has_protected_content' => true,
         'from' => [
             'id' => 1,
             'is_bot' => true,
@@ -18,6 +20,13 @@ it('export all properties to array', function () {
             'last_name' => 'b',
             'username' => 'c',
         ],
+        'forward_from' => [
+            'id' => 1,
+            'is_bot' => true,
+            'first_name' => 'a',
+            'last_name' => 'b',
+            'username' => 'c',
+        ],
         'chat' => [
             'id' => 3,
             'type' => 'a',
@@ -26,6 +35,60 @@ it('export all properties to array', function () {
         'reply_markup' => [
             'inline_keyboard' => Keyboard::make()->button('test')->url('a')->toArray(),
         ],
+        'photo' => [
+            [
+                'file_id' => 99,
+                'width' => 1024,
+                'height' => 768,
+                'file_size' => 42,
+            ],
+        ],
+        'audio' => [
+            'file_id' => 31,
+            'duration' => 666,
+            'title' => 'my audio',
+            'file_name' => 'My Audio.mp3',
+            'mime_type' => 'audio/mp3',
+            'file_size' => 42,
+            'thumb' => [
+                'file_id' => 99,
+                'width' => 1024,
+                'height' => 768,
+                'file_size' => 42,
+            ],
+        ],
+        'document' => [
+            'file_id' => 45,
+            'file_name' => 'My Document.pdf',
+            'mime_type' => 'application.pdf',
+            'file_size' => 42,
+            'thumb' => [
+                'file_id' => 99,
+                'width' => 1024,
+                'height' => 768,
+                'file_size' => 42,
+            ],
+        ],
+        'video' => [
+            'file_id' => 31,
+            'width' => 1024,
+            'height' => 768,
+            'duration' => 666,
+            'file_name' => 'My Audio.mp3',
+            'mime_type' => 'audio/mp3',
+            'file_size' => 42,
+            'thumb' => [
+                'file_id' => 99,
+                'width' => 1024,
+                'height' => 768,
+                'file_size' => 42,
+            ],
+        ],
+        'location' => [
+            'latitude' => 12456789,
+            'longitude' => 98765431,
+            'horizontal_accuracy' => 3,
+        ],
     ]);
 
     $array = $dto->toArray();
diff --git a/tests/Unit/DTO/PhotoTest.php b/tests/Unit/DTO/PhotoTest.php
new file mode 100644
index 00000000..3e963f84
--- /dev/null
+++ b/tests/Unit/DTO/PhotoTest.php
@@ -0,0 +1,22 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Photo;
+use Illuminate\Support\Str;
+
+it('export all properties to array', function () {
+    $dto = Photo::fromArray([
+        'file_id' => 99,
+        'width' => 1024,
+        'height' => 768,
+        'file_size' => 42,
+    ]);
+
+    $array = $dto->toArray();
+
+    $reflection = new ReflectionClass($dto);
+    foreach ($reflection->getProperties() as $property) {
+        expect($array)->toHaveKey(Str::of($property->name)->snake());
+    }
+});
diff --git a/tests/Unit/DTO/VideoTest.php b/tests/Unit/DTO/VideoTest.php
new file mode 100644
index 00000000..8cdc1a90
--- /dev/null
+++ b/tests/Unit/DTO/VideoTest.php
@@ -0,0 +1,31 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+use DefStudio\Telegraph\DTO\Video;
+use Illuminate\Support\Str;
+
+it('export all properties to array', function () {
+    $dto = Video::fromArray([
+        'file_id' => 31,
+        'width' => 1024,
+        'height' => 768,
+        'duration' => 666,
+        'file_name' => 'My Audio.mp3',
+        'mime_type' => 'audio/mp3',
+        'file_size' => 42,
+        'thumb' => [
+            'file_id' => 99,
+            'width' => 1024,
+            'height' => 768,
+            'file_size' => 42,
+        ],
+    ]);
+
+    $array = $dto->toArray();
+
+    $reflection = new ReflectionClass($dto);
+    foreach ($reflection->getProperties() as $property) {
+        expect($array)->toHaveKey(Str::of($property->name)->snake());
+    }
+});
diff --git a/tests/Unit/Models/TelegraphBotTest.php b/tests/Unit/Models/TelegraphBotTest.php
index 6cc59d06..e53949c8 100644
--- a/tests/Unit/Models/TelegraphBotTest.php
+++ b/tests/Unit/Models/TelegraphBotTest.php
@@ -114,3 +114,13 @@ it('throws an exception if a webhook is set up', function () {
 
     $bot->updates();
 })->throws(TelegramUpdatesException::class, 'Cannot retrieve updates for Test Bot bot while a webhook is set. First, delete the webhook with [artisan telegraph:delete-webhook 42] or programmatically calling [$bot->deleteWebhook()]');
+
+it('can store a downloadable file', function () {
+    Telegraph::fake();
+
+    $bot = make_bot();
+
+    $bot->store('123456', 'test/bots');
+
+    Telegraph::assertStoredFile('123456');
+});
diff --git a/tests/__snapshots__/SendsAttachmentsTest__it_can_send_a_photo_protectint_it_from_sharing__1.yml b/tests/__snapshots__/SendsAttachmentsTest__it_can_send_a_photo_protecting_it_from_sharing__1.yml
similarity index 100%
rename from tests/__snapshots__/SendsAttachmentsTest__it_can_send_a_photo_protectint_it_from_sharing__1.yml
rename to tests/__snapshots__/SendsAttachmentsTest__it_can_send_a_photo_protecting_it_from_sharing__1.yml
diff --git a/tests/__snapshots__/StoreFilesTest__it_can_get_file_info__1.yml b/tests/__snapshots__/StoreFilesTest__it_can_get_file_info__1.yml
new file mode 100644
index 00000000..02e4bb88
--- /dev/null
+++ b/tests/__snapshots__/StoreFilesTest__it_can_get_file_info__1.yml
@@ -0,0 +1,4 @@
+url: 'https://api.telegram.org/bot3f3814e1-5836-3d77-904e-60f64b15df36/getFile'
+payload:
+    file_id: 123456aaa
+files: {  }