diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d4d20..66ac9d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### New Features + +#### Outgoing MMS + +- **Outgoing MMS support** — pass an `MmsMessage` (instead of a text string) to `Message` or `MessageBuilder` to send an MMS with an optional subject, body, and attachments: + ```php + $mms = new MmsMessage('Subject', 'Body', [ + new MmsAttachment('image/png', base64_encode($bytes), 'photo.png'), + ]); + $message = (new MessageBuilder($mms, ['+15550100']))->build(); + ``` + Attachments carry a MIME type, base64-encoded data, and an optional filename; null subject, text, and filename are omitted from the request body. MMS fields are encrypted when an `Encryptor` is set, and an MMS message is mutually exclusive with an SMS text body on the wire. + +## [2.6.1] - 2026-09-23 + ### Bug Fixes - **Message priority always serialized** — a message's `priority` is now always included in the JSON body (defaulting to `0` when unset), matching the wire format of the other official SDKs. diff --git a/README.md b/README.md index ea7aa7d..66e0d15 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A modern PHP client for the [SMSGate](https://sms-gate.app) API: send SMS messag ## 📖 About -`capcom6/android-sms-gateway` is a type-safe PHP library for the SMSGate 3rd-party API. It covers messages (send, state, listing, cancellation), inbox refresh with individual or batch webhook delivery and attachment download, devices, webhooks, settings, logs, health checks, and the JWT token lifecycle, with a fluent `MessageBuilder` for message construction and an optional `Encryptor` for end-to-end encryption. Works with any PSR-18 HTTP client (Guzzle, curl, or others) and PHP 7.4+. +`capcom6/android-sms-gateway` is a type-safe PHP library for the SMSGate 3rd-party API. It covers messages (send SMS and MMS, state, listing, cancellation), inbox refresh with individual or batch webhook delivery and attachment download, devices, webhooks, settings, logs, health checks, and the JWT token lifecycle, with a fluent `MessageBuilder` for message construction and an optional `Encryptor` for end-to-end encryption. Works with any PSR-18 HTTP client (Guzzle, curl, or others) and PHP 7.4+. ## 📚 Table of Contents @@ -33,7 +33,7 @@ A modern PHP client for the [SMSGate](https://sms-gate.app) API: send SMS messag ## ⭐ Features - Fluent `MessageBuilder` for messages and `SettingsBuilder` for settings -- Messages: send, state, listing, and cancellation +- Messages: send SMS text or MMS (subject, body, attachments), state, listing, and cancellation - PSR-18 HTTP client and PSR-17 factories, auto-discovered - Basic and JWT authentication with token generation and revocation - Inbox refresh with individual or batch webhook delivery and MMS attachment download diff --git a/src/Domain/Message.php b/src/Domain/Message.php index 576e676..6bacb86 100644 --- a/src/Domain/Message.php +++ b/src/Domain/Message.php @@ -13,11 +13,18 @@ class Message implements SerializableInterface { * Message ID, will be generated automatically if not set */ private ?string $id; + /** * Message text * Long message will be divided into parts */ private string $message; + /** + * MMS message content (mutually exclusive with text/data messages) + * @var MmsMessage|null + */ + private ?MmsMessage $mmsMessage; + /** * Time to live in seconds * If message is not received by device in this time, it will be failed @@ -50,10 +57,11 @@ class Message implements SerializableInterface { private ?string $validUntil; /** + * @param mixed $message string or MmsMessage * @param array $phoneNumbers */ public function __construct( - string $message, + $message, array $phoneNumbers, ?string $id = null, ?int $ttl = null, @@ -62,12 +70,19 @@ public function __construct( ?int $priority = null, ?string $validUntil = null ) { + if (is_scalar($message)) { + $message = (string) $message; + } + + if (!is_string($message) && !($message instanceof MmsMessage)) { + throw new \InvalidArgumentException('message must be a string or MmsMessage'); + } + if ($ttl !== null && $validUntil !== null) { throw new \InvalidArgumentException('validUntil and ttl cannot be set at the same time'); } $this->id = $id; - $this->message = $message; $this->ttl = $ttl; $this->simNumber = $simNumber; $this->withDeliveryReport = $withDeliveryReport; @@ -75,6 +90,23 @@ public function __construct( $this->isEncrypted = false; $this->priority = $priority; $this->validUntil = $validUntil; + + if ($message instanceof MmsMessage) { + $this->mmsMessage = $message; + $this->message = ''; + } else { + $this->message = $message; + $this->mmsMessage = null; + } + } + + + /** + * Get MMS message content (null if not set) + * @return MmsMessage|null + */ + public function MmsMessage(): ?MmsMessage { + return $this->mmsMessage; } public function Encrypt(Encryptor $encryptor): self { @@ -88,19 +120,46 @@ public function Encrypt(Encryptor $encryptor): self { fn(string $phoneNumber) => $encryptor->Encrypt($phoneNumber), $this->phoneNumbers ); + + if ($this->mmsMessage !== null) { + $this->mmsMessage = new MmsMessage( + $this->mmsMessage->Subject() !== null + ? $encryptor->Encrypt($this->mmsMessage->Subject()) + : null, + $this->mmsMessage->Text() !== null + ? $encryptor->Encrypt($this->mmsMessage->Text()) + : null, + array_map( + fn(MmsAttachment $attachment) => new MmsAttachment( + $attachment->ContentType(), + $encryptor->Encrypt($attachment->Data()), + $attachment->Name() !== null + ? $encryptor->Encrypt($attachment->Name()) + : null + ), + $this->mmsMessage->Attachments() + ) + ); + } + return $this; } public function ToObject(): object { $obj = (object) [ 'id' => $this->id, - 'message' => $this->message, 'simNumber' => $this->simNumber, 'withDeliveryReport' => $this->withDeliveryReport, 'isEncrypted' => $this->isEncrypted, 'phoneNumbers' => $this->phoneNumbers, ]; + if ($this->mmsMessage !== null) { + $obj->mmsMessage = $this->mmsMessage->ToObject(); + } else { + $obj->message = $this->message; + } + if ($this->priority !== null) { $obj->priority = $this->priority; } diff --git a/src/Domain/MessageBuilder.php b/src/Domain/MessageBuilder.php index c2c6d4b..46ebfb8 100644 --- a/src/Domain/MessageBuilder.php +++ b/src/Domain/MessageBuilder.php @@ -14,9 +14,9 @@ class MessageBuilder { private ?string $id = null; /** - * @var string + * @var string|MmsMessage */ - private string $message; + private $message; /** * @var int|null @@ -50,10 +50,10 @@ class MessageBuilder { private ?string $validUntil = null; /** - * @param string $message + * @param string|MmsMessage $message * @param array $phoneNumbers */ - public function __construct(string $message, array $phoneNumbers) { + public function __construct($message, array $phoneNumbers) { $this->message = $message; $this->phoneNumbers = $phoneNumbers; } @@ -72,10 +72,10 @@ public function setId(?string $id): self { /** * Set message text * - * @param string $message + * @param string|MmsMessage $message * @return $this */ - public function setMessage(string $message): self { + public function setMessage($message): self { $this->message = $message; return $this; } diff --git a/src/Domain/MmsAttachment.php b/src/Domain/MmsAttachment.php new file mode 100644 index 0000000..da15c18 --- /dev/null +++ b/src/Domain/MmsAttachment.php @@ -0,0 +1,63 @@ +contentType = $contentType; + $this->data = $data; + $this->name = $name; + } + + public function ContentType(): string { + return $this->contentType; + } + + public function Data(): string { + return $this->data; + } + + public function Name(): ?string { + return $this->name; + } + + public function ToObject(): \stdClass { + $obj = (object) [ + 'contentType' => $this->contentType, + 'name' => $this->name, + 'data' => $this->data, + ]; + + if ($this->name === null) { + unset($obj->name); + } + + return $obj; + } +} diff --git a/src/Domain/MmsMessage.php b/src/Domain/MmsMessage.php new file mode 100644 index 0000000..a334f30 --- /dev/null +++ b/src/Domain/MmsMessage.php @@ -0,0 +1,77 @@ + + */ + private array $attachments; + + /** + * @param string|null $subject Optional subject of the MMS + * @param string|null $text Optional text body of the MMS + * @param array $attachments List of attachments + */ + public function __construct(?string $subject = null, ?string $text = null, array $attachments = []) { + $this->subject = $subject; + $this->text = $text; + $this->attachments = array_values($attachments); + } + + public function Subject(): ?string { + return $this->subject; + } + + public function Text(): ?string { + return $this->text; + } + + /** + * @return array + */ + public function Attachments(): array { + return $this->attachments; + } + + public function ToObject(): \stdClass { + $obj = (object) [ + 'subject' => $this->subject, + 'text' => $this->text, + 'attachments' => array_map( + static fn(MmsAttachment $attachment) => $attachment->ToObject(), + $this->attachments + ), + ]; + + if ($this->subject === null) { + unset($obj->subject); + } + + if ($this->text === null) { + unset($obj->text); + } + + if (count($this->attachments) === 0) { + unset($obj->attachments); + } + + return $obj; + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 4bbf742..97c7ea1 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -113,7 +113,7 @@ public function testServer(): void { ); $client = new Client($login, $password, Client::DEFAULT_URL, $curlClient); - $message = new Message(date('Y-m-d H:i:s'), [$phoneNumber]); + $message = new Message('Test message from `client-php`' . PHP_EOL . date('Y-m-d H:i:s'), [$phoneNumber]); $messageState = $client->SendMessage($message); $this->assertInstanceOf(MessageState::class, $messageState); diff --git a/tests/Domain/MessageTest.php b/tests/Domain/MessageTest.php index 52b28ec..48e84bc 100644 --- a/tests/Domain/MessageTest.php +++ b/tests/Domain/MessageTest.php @@ -4,9 +4,14 @@ use PHPUnit\Framework\TestCase; use AndroidSmsGateway\Domain\Message; +use AndroidSmsGateway\Domain\MmsAttachment; +use AndroidSmsGateway\Domain\MmsMessage; +use AndroidSmsGateway\Encryptor; use AndroidSmsGateway\Interfaces\SerializableInterface; final class MessageTest extends TestCase { + private const MMS_FIXTURE = '{"subject":"Hello","text":"World","attachments":[{"contentType":"image/png","name":"picture.png","data":"BASE64DATA"}]}'; + public function testCanBeInstantiated(): void { $message = new Message('Hello', ['+1234567890']); $this->assertInstanceOf(Message::class, $message); @@ -62,8 +67,144 @@ public function testLegacyTextMessageStillSerializesMessageKey(): void { $json = json_encode($message->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $this->assertSame( - '{"id":null,"message":"Hello","simNumber":null,"withDeliveryReport":true,"isEncrypted":false,"phoneNumbers":["+1234567890"],"priority":0}', + '{"id":null,"simNumber":null,"withDeliveryReport":true,"isEncrypted":false,"phoneNumbers":["+1234567890"],"message":"Hello","priority":0}', $json ); } + + public function testMmsMessageToObjectMatchesLockedFixtureByteExact(): void { + $mms = new MmsMessage('Hello', 'World', [new MmsAttachment('image/png', 'BASE64DATA', 'picture.png')]); + + $this->assertSame(self::MMS_FIXTURE, json_encode($mms->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + public function testMmsAttachmentToObjectOmitsNullName(): void { + $attachment = new MmsAttachment('image/png', 'BASE64DATA'); + + $this->assertSame( + '{"contentType":"image/png","data":"BASE64DATA"}', + json_encode($attachment->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + } + + public function testMmsMessageOmitsNullSubjectTextAndEmptyAttachments(): void { + $mms = new MmsMessage(); + + $this->assertSame('{}', json_encode($mms->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } + + public function testMmsMessageOmitsSubjectOnlyWhenNull(): void { + $mms = new MmsMessage(null, 'World', [new MmsAttachment('image/png', 'BASE64DATA')]); + + $this->assertSame( + '{"text":"World","attachments":[{"contentType":"image/png","data":"BASE64DATA"}]}', + json_encode($mms->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + } + + public function testMessageSerializesMmsMessageWithoutMessageKey(): void { + $mms = new MmsMessage('Hello', 'World', [new MmsAttachment('image/png', 'BASE64DATA', 'picture.png')]); + $message = new Message($mms, ['+1234567890'], null, null, null, true, null, null); + + $json = json_encode($message->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $this->assertStringNotContainsString('"message"', $json); + $this->assertSame( + '{"id":null,"simNumber":null,"withDeliveryReport":true,"isEncrypted":false,"phoneNumbers":["+1234567890"],"mmsMessage":' . self::MMS_FIXTURE . ',"priority":0}', + $json + ); + } + + public function testMessageRejectsUnsupportedContent(): void { + $this->expectException(\InvalidArgumentException::class); + + new Message([], ['+1234567890']); + } + + public function testMmsMessageNormalizesSparseAttachmentKeysToList(): void { + $mms = new MmsMessage('Hello', 'World', [ + 5 => new MmsAttachment('image/png', 'BASE64DATA', 'picture.png'), + ]); + + $this->assertSame( + '{"subject":"Hello","text":"World","attachments":[{"contentType":"image/png","name":"picture.png","data":"BASE64DATA"}]}', + json_encode($mms->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + } + + public function testMessageMmsMessageDecodesToFixtureShape(): void { + $mms = new MmsMessage('Hello', 'World', [new MmsAttachment('image/png', 'BASE64DATA', 'picture.png')]); + $message = new Message($mms, ['+1234567890'], null, null, null, true, null, null); + + $json = json_encode($message->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + /** @var array $decoded */ + $decoded = json_decode($json, true); + + $this->assertSame([ + 'subject' => 'Hello', + 'text' => 'World', + 'attachments' => [ + ['contentType' => 'image/png', 'name' => 'picture.png', 'data' => 'BASE64DATA'], + ], + ], $decoded['mmsMessage']); + } + + public function testMessageOmitsMmsMessageWhenNotSet(): void { + $message = new Message('Hello', ['+1234567890']); + + $json = json_encode($message->ToObject(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + $this->assertStringNotContainsString('mmsMessage', $json); + } + + public function testEncryptEncryptsMmsMessageFields(): void { + $encryptor = new Encryptor('passphrase'); + $mms = new MmsMessage('Hello', 'World', [new MmsAttachment('image/png', 'BASE64DATA', 'picture.png')]); + $message = new Message($mms, ['+1234567890'], null, null, null, true, null, null); + + $message->Encrypt($encryptor); + + /** @var MmsMessage $encryptedMms */ + $encryptedMms = $message->MmsMessage(); + + $this->assertNotSame('Hello', $encryptedMms->Subject()); + $this->assertNotSame('World', $encryptedMms->Text()); + $this->assertNotSame('BASE64DATA', $encryptedMms->Attachments()[0]->Data()); + $this->assertNotSame('picture.png', $encryptedMms->Attachments()[0]->Name()); + $this->assertSame('image/png', $encryptedMms->Attachments()[0]->ContentType()); + + $this->assertSame('Hello', $encryptor->Decrypt($encryptedMms->Subject())); + $this->assertSame('World', $encryptor->Decrypt($encryptedMms->Text())); + $this->assertSame('BASE64DATA', $encryptor->Decrypt($encryptedMms->Attachments()[0]->Data())); + $this->assertSame('picture.png', $encryptor->Decrypt($encryptedMms->Attachments()[0]->Name())); + } + + public function testEncryptIsIdempotentForMmsMessage(): void { + $encryptor = new Encryptor('passphrase'); + $mms = new MmsMessage('Hello', 'World', [new MmsAttachment('image/png', 'BASE64DATA')]); + $message = new Message($mms, ['+1234567890'], null, null, null, true, null, null); + + $message->Encrypt($encryptor); + + /** @var MmsMessage $firstPass */ + $firstPass = $message->MmsMessage(); + $subjectAfterFirstPass = $firstPass->Subject(); + + $message->Encrypt($encryptor); + + /** @var MmsMessage $secondPass */ + $secondPass = $message->MmsMessage(); + $this->assertSame($subjectAfterFirstPass, $secondPass->Subject()); + } + + public function testEncryptWithoutMmsMessageStillEncryptsText(): void { + $encryptor = new Encryptor('passphrase'); + $message = new Message('Hello', ['+1234567890']); + + $message->Encrypt($encryptor); + + $this->assertTrue($message->ToObject()->isEncrypted); + $this->assertNotSame('Hello', $message->ToObject()->message); + } }