Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.0] - 2026-07-17

### Added
- Initial release of the STROMCOM Customer SDK for PHP.
- `Client` covering Project, Users, Threads, Messages and Attachment endpoints.
Expand All @@ -27,3 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- PHPUnit 11 test suite (78 tests, 210 assertions), PHPStan level 9,
php-cs-fixer config.
- GitHub Actions CI for PHP 8.3 and 8.4.
- `MessageResource::reactions()` — list emoji reactions on a message,
aggregated by code (`MessageReaction`, `MessageReactionUser` models).
- `ThreadResource::create()` — upsert a thread by code, optionally with an
initial message and attachments.
- `ThreadResource::update()` — update thread `name` / `url` / `attributes`.
- `MessageResource::create()` — send a message (with attachments) in an
existing thread.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ $stromcom->users()->update($hash, ['name' => 'Jane Smith']);
$stromcom->users()->delete($hash); // soft delete, idempotent
$stromcom->users()->restore($hash);

// Create a thread (upsert by code) with an optional initial message.
// The author must be identified via user_hash/user_code when a message/
// attachments are present — the request isn't bound to a logged-in user.
$created = $stromcom->threads()->create([
'code' => 'order-1234',
'name' => 'Order #1234',
'message' => '<p>Your order has shipped.</p>',
'user_code' => 'employee-42',
]);
$thread = $created['thread']; // Thread
$message = $created['message']; // ?Message

$stromcom->threads()->update('order-1234', ['name' => 'Order #1234 (shipped)']);

// Send a follow-up message in the same thread
$stromcom->messages()->create('order-1234', [
'message' => '<p>Tracking number: 1Z999AA10123456784</p>',
'user_code' => 'employee-42',
]);

// Thread + messages + attachments in one round-trip
$result = $stromcom->threads()->getWithMessages('order-1234');
$thread = $result['thread']; // Thread
Expand Down Expand Up @@ -87,8 +107,10 @@ covering each area:
| [`project_settings.php`](examples/project_settings.php) | Read & PATCH project policy flags. |
| [`users_lifecycle.php`](examples/users_lifecycle.php) | Create → fetch → update → delete → restore. |
| [`thread_with_messages.php`](examples/thread_with_messages.php) | Thread + messages + attachments in one call. |
| [`create_thread_and_message.php`](examples/create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. |
| [`paginate_messages.php`](examples/paginate_messages.php) | Cursor pagination via `lastMessageHash`. |
| [`download_attachment.php`](examples/download_attachment.php) | Resolve and save attachments to disk. |
| [`message_reactions.php`](examples/message_reactions.php) | List emoji reactions on a message, aggregated by code. |
| [`error_handling.php`](examples/error_handling.php) | Catch the typed exception hierarchy. |
| [`custom_http_client.php`](examples/custom_http_client.php) | Plug a custom `HttpClientInterface`. |

Expand All @@ -114,6 +136,7 @@ keys land as `null`.
| `Message`, `MessageList` | `messages()->list()`, `threads()->getWithMessages()` |
| `MessageAttachment` | `messages()->attachments()`, `MessageList::attachmentsFor()` |
| `AttachmentDownload` | `messages()->attachmentDownload()` |
| `MessageReaction`, `MessageReactionUser` | `messages()->reactions()` |

## Method reference

Expand All @@ -131,14 +154,18 @@ void $stromcom->users()->delete(string $hash);
User $stromcom->users()->restore(string $hash);

// Threads
array{thread: Thread, message: ?Message, attachmentsUploadURL: list<string>} $stromcom->threads()->create(array $payload);
Thread $stromcom->threads()->update(string $code, array $changes);
Thread $stromcom->threads()->get(string $code, array $expand = []);
array{thread: Thread, messages: MessageList} $stromcom->threads()->getWithMessages(string $code);

// Messages
array{message: Message, attachmentsUploadURL: list<string>} $stromcom->messages()->create(string $threadCode, array $payload);
MessageList $stromcom->messages()->list(string $threadCode, ?string $lastMessageHash = null);
void $stromcom->messages()->delete(string $threadCode, string $messageHash);
list<MessageAttachment> $stromcom->messages()->attachments(string $threadCode, string $messageHash);
AttachmentDownload $stromcom->messages()->attachmentDownload(string $threadCode, string $messageHash, string $attachmentHash);
list<MessageReaction> $stromcom->messages()->reactions(string $threadCode, string $messageHash);
```

## Configuration
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ php examples/quickstart.php
| [`project_settings.php`](project_settings.php) | Read project settings, then PATCH a couple of policy flags. |
| [`users_lifecycle.php`](users_lifecycle.php) | Full user lifecycle: create → fetch → update → delete → restore. |
| [`thread_with_messages.php`](thread_with_messages.php) | Single-call fetch of a thread, its messages, and grouped attachments. |
| [`create_thread_and_message.php`](create_thread_and_message.php) | Create/upsert a thread with an initial message, update it, send a follow-up message. |
| [`paginate_messages.php`](paginate_messages.php) | Walk every message in a thread via the `lastMessageHash` cursor. |
| [`download_attachment.php`](download_attachment.php) | Resolve attachment metadata, fetch a signed URL, save to disk. |
| [`message_reactions.php`](message_reactions.php) | List emoji reactions on a message, aggregated by code. |
| [`error_handling.php`](error_handling.php) | Catch the typed exception hierarchy (`NotFound`, `Validation`, …). |
| [`custom_http_client.php`](custom_http_client.php) | Plug a custom `HttpClientInterface` (logging wrapper here). |

Expand Down
55 changes: 55 additions & 0 deletions examples/create_thread_and_message.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);

/**
* Create a thread (upsert by code) with an initial message, then update the
* thread name and send a follow-up message.
*
* threads()->create($payload)
* → ['thread' => Thread, 'message' => ?Message, 'attachmentsUploadURL' => list<string>]
* threads()->update($code, $changes)
* → Thread
* messages()->create($threadCode, $payload)
* → ['message' => Message, 'attachmentsUploadURL' => list<string>]
*
* Requests are not bound to a logged-in user — whenever a message or
* attachments are present, the author must be identified via `user_hash`
* or `user_code`.
*
* Run with:
* STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_USER_CODE=employee-42 \
* php examples/create_thread_and_message.php
*/

require __DIR__ . '/../vendor/autoload.php';

use Stromcom\Sdk\Client;

$token = getenv('STROMCOM_TOKEN') ?: '';
$threadCode = getenv('STROMCOM_THREAD') ?: '';
$userCode = getenv('STROMCOM_USER_CODE') ?: '';
if ($token === '' || $threadCode === '' || $userCode === '') {
fwrite(STDERR, "Set STROMCOM_TOKEN, STROMCOM_THREAD and STROMCOM_USER_CODE.\n"); exit(1);
}

$stromcom = new Client($token);

$created = $stromcom->threads()->create([
'code' => $threadCode,
'name' => "Order {$threadCode}",
'message' => '<p>Your order has shipped.</p>',
'user_code' => $userCode,
]);
printf("Thread created/updated: %s (%s)\n", $created['thread']->name, $created['thread']->hash);
if ($created['message'] !== null) {
printf("Initial message: %s\n", $created['message']->hash);
}

$updated = $stromcom->threads()->update($threadCode, ['name' => "Order {$threadCode} (shipped)"]);
printf("Thread renamed to: %s\n", $updated->name);

$sent = $stromcom->messages()->create($threadCode, [
'message' => '<p>Tracking number: 1Z999AA10123456784</p>',
'user_code' => $userCode,
]);
printf("Follow-up message: %s\n", $sent['message']->hash);
46 changes: 46 additions & 0 deletions examples/message_reactions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);

/**
* List the emoji reactions on a message, aggregated by emoji code.
*
* messages()->reactions($threadCode, $messageHash)
* → list<MessageReaction> (code, count, currentUserReacted, users)
*
* Read-only: reacting to a message is only available to end users via the
* App API, not to the customer credential.
*
* Run with:
* STROMCOM_TOKEN=… STROMCOM_THREAD=order-1234 STROMCOM_MESSAGE=eVO6SY5kf5 \
* php examples/message_reactions.php
*/

require __DIR__ . '/../vendor/autoload.php';

use Stromcom\Sdk\Client;

$token = getenv('STROMCOM_TOKEN') ?: '';
$threadCode = getenv('STROMCOM_THREAD') ?: '';
$messageHash = getenv('STROMCOM_MESSAGE') ?: '';
if ($token === '' || $threadCode === '' || $messageHash === '') {
fwrite(STDERR, "Set STROMCOM_TOKEN, STROMCOM_THREAD and STROMCOM_MESSAGE.\n"); exit(1);
}

$stromcom = new Client($token);
$reactions = $stromcom->messages()->reactions($threadCode, $messageHash);

if ($reactions === []) {
echo "Message has no reactions.\n";
exit(0);
}

foreach ($reactions as $reaction) {
$userNames = implode(', ', array_map(static fn($user) => $user->name, $reaction->users));
printf(
"%s x%d%s — %s\n",
$reaction->code,
$reaction->count,
$reaction->currentUserReacted ? ' (you)' : '',
$userNames,
);
}
43 changes: 43 additions & 0 deletions src/Model/MessageReaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);

namespace Stromcom\Sdk\Model;

/**
* Emoji reactions on a message, aggregated by emoji code. `currentUserReacted`
* reflects the credential the SDK authenticates as — read-only for customers,
* reacting itself is only available to end users via the App API.
*/
final class MessageReaction extends AbstractModel {

/** @param list<MessageReactionUser> $users */
public function __construct(
public readonly string $code,
public readonly int $count,
public readonly bool $currentUserReacted,
public readonly array $users,
) {
}

public static function fromArray(array $data): static {
/** @var list<array<string, mixed>> $rawUsers */
$rawUsers = isset($data['users']) && is_array($data['users']) ? array_values($data['users']) : [];

return new self(
code: self::requireString($data, 'code'),
count: self::readInt($data, 'count') ?? 0,
currentUserReacted: self::readBool($data, 'currentUserReacted') ?? false,
users: MessageReactionUser::listFromArray($rawUsers),
);
}

/**
* @param list<array<string, mixed>> $items
*
* @return list<self>
*/
public static function listFromArray(array $items): array {
return array_map(static fn(array $row): self => self::fromArray($row), $items);
}

}
33 changes: 33 additions & 0 deletions src/Model/MessageReactionUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);

namespace Stromcom\Sdk\Model;

/**
* A single user who reacted with a given emoji on a message.
*/
final class MessageReactionUser extends AbstractModel {

public function __construct(
public readonly string $hash,
public readonly string $name,
) {
}

public static function fromArray(array $data): static {
return new self(
hash: self::requireString($data, 'hash'),
name: self::requireString($data, 'name'),
);
}

/**
* @param list<array<string, mixed>> $items
*
* @return list<self>
*/
public static function listFromArray(array $items): array {
return array_map(static fn(array $row): self => self::fromArray($row), $items);
}

}
47 changes: 46 additions & 1 deletion src/Resource/MessageResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,44 @@
namespace Stromcom\Sdk\Resource;

use Stromcom\Sdk\Model\AttachmentDownload;
use Stromcom\Sdk\Model\Message;
use Stromcom\Sdk\Model\MessageAttachment;
use Stromcom\Sdk\Model\MessageList;
use Stromcom\Sdk\Model\MessageReaction;

/**
* `/threads/{code}/messages/` — read messages and attachments, admin-delete.
* `/threads/{code}/messages/` — create, read messages and attachments, admin-delete.
*/
final class MessageResource extends AbstractResource {

/**
* POST /threads/{code}/messages/ — create a message in an existing thread.
* The author is identified via `user_hash` or `user_code` in $payload
* (requests are not bound to a logged-in user). Attachments are uploaded
* in two steps: pass file names in `attachments`, then PUT each file's
* bytes to the matching `attachmentsUploadURL` entry.
*
* @param array<string, mixed> $payload
*
* @return array{message: Message, attachmentsUploadURL: list<string>}
*/
public function create(string $threadCode, array $payload): array {
/** @var array<string, mixed> $data */
$data = $this->client->request('POST', "threads/{$threadCode}/messages/", body: $payload) ?? [];

/** @var array<string, mixed> $messageData */
$messageData = isset($data['message']) && is_array($data['message']) ? $data['message'] : [];
/** @var list<string> $uploadURLs */
$uploadURLs = isset($data['attachmentsUploadURL']) && is_array($data['attachmentsUploadURL'])
? array_values($data['attachmentsUploadURL'])
: [];

return [
'message' => Message::fromArray($messageData),
'attachmentsUploadURL' => $uploadURLs,
];
}

/**
* GET /threads/{code}/messages/
*
Expand Down Expand Up @@ -57,4 +87,19 @@ public function attachmentDownload(string $threadCode, string $messageHash, stri
return AttachmentDownload::fromArray($data);
}

/**
* GET /threads/{code}/messages/{messageHash}/reactions/ — emoji reactions
* aggregated by code. Read-only: reacting is only available to end users
* via the App API, not to the customer credential.
*
* @return list<MessageReaction>
*/
public function reactions(string $threadCode, string $messageHash): array {
/** @var array<string, mixed> $data */
$data = $this->client->request('GET', "threads/{$threadCode}/messages/{$messageHash}/reactions/") ?? [];
/** @var list<array<string, mixed>> $rawReactions */
$rawReactions = isset($data['reactions']) && is_array($data['reactions']) ? array_values($data['reactions']) : [];
return MessageReaction::listFromArray($rawReactions);
}

}
Loading