Skip to content
Merged
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
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.1.0] - 2026-10-16


### Added
- Initial release of the STROMCOM Customer SDK for PHP.
Expand All @@ -27,3 +28,23 @@ 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.

### Fixed

- `CurlHttpClient` rejects an empty HTTP method or URL with `TransportException` instead of passing it to cURL.
- README: OpenAPI spec link now points to `/docs/customer/openapi.json` (was `swagger.json`).
- `examples/paginate_messages.php` rewritten as incremental sync — the `lastMessageHash` cursor is inclusive and the API has no page size.

### Documentation

- README: how the `lastMessageHash` cursor behaves (oldest-first, inclusive, no page size).
- README: 402 plan-limit and 403 `ACTION_FORBIDDEN` errors surface as `ApiException`; branch on `getApiCode()`.
- README: required token type (`customer` / `both`, server-side only) and where to create it.
- README: known limitation — response `meta` (warnings, `meta.updated`) is not exposed.
89 changes: 84 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@ composer require stromcom/customer-sdk

## API access & documentation

- **Reference docs:** <https://www.stromcom.cz/docs/customer/>
- **OpenAPI spec:** <https://www.stromcom.cz/docs/customer/swagger.json>
- **Reference docs (Swagger UI):** <https://www.stromcom.cz/docs/customer/>
- **OpenAPI spec:** <https://www.stromcom.cz/docs/customer/openapi.json>

API access is gated by your STROMCOM subscription — only plans that
explicitly include API access can issue Customer API tokens and call
the endpoints below. If your token returns `401 Unauthorized` or your
project's `allowAPI` flag is `false`, check your current plan in the
STROMCOM console or contact support to upgrade.

The token must be an API key of type **`customer`** (or `both`), created
in the customer panel under *Project → Credentials*
(`/customer/project/{id}/credentials/`). It grants full access to the
project — use it **server-side only**, never in browser or mobile code.

## Quick start

```php
Expand Down Expand Up @@ -54,6 +59,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 +112,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. |
| [`paginate_messages.php`](examples/paginate_messages.php) | Cursor pagination via `lastMessageHash`. |
| [`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) | Incremental sync via the `lastMessageHash` cursor. |
| [`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 +141,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,16 +159,34 @@ 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);
```

### Message cursor (`lastMessageHash`)

`messages()->list($threadCode, $lastMessageHash)` is not page-based:

- messages are returned **oldest-first**;
- the cursor is **inclusive** — the first returned message is the cursor
message itself;
- there is **no page size** — the server returns every message from the
cursor onward (limited only by your plan's history window).

Use it for incremental sync: store the hash of the newest message you
have, fetch from it next time and drop the first item when it equals the
cursor. See [`examples/paginate_messages.php`](examples/paginate_messages.php).

## Configuration

```php
Expand Down Expand Up @@ -170,11 +216,11 @@ All SDK exceptions extend `Stromcom\Sdk\Exception\StromcomException`.
| `AuthenticationException` | 401 — invalid / missing token |
| `NotFoundException` | 404 — resource not found |
| `ValidationException` | 400 / 422 — invalid input |
| `ConflictException` | 409 — e.g. user code already exists, limit reached |
| `ConflictException` | 409 — e.g. user code already exists |
| `RateLimitException` | 429 — slow down |
| `ServerException` | 5xx |
| `TransportException` | network failure / non-JSON response |
| `ApiException` | catch-all base for any other API error |
| `ApiException` | base class; thrown as-is for any other status, notably 402 and 403 (see below) |

Every API exception exposes:

Expand All @@ -189,9 +235,42 @@ try {
}
```

Plan limits (**402**) and forbidden actions (**403**) have no dedicated
subclass — they surface as a plain `ApiException`. Catch the specific
exceptions first, then `ApiException`, and branch on `getApiCode()`:

| Status | `getApiCode()` |
|---|---|
| 402 | `USER_LIMIT_REACHED`, `THREAD_LIMIT_REACHED`, `MESSAGE_LIMIT_REACHED`, `STORAGE_LIMIT_REACHED`, `MESSAGE_STORAGE_LIMIT_REACHED`, `WEBHOOK_LIMIT_REACHED` |
| 403 | `ACTION_FORBIDDEN` |

```php
use Stromcom\Sdk\Exception\ApiException;
use Stromcom\Sdk\Exception\ValidationException;

try {
$stromcom->messages()->create('order-1234', $payload);
} catch (ValidationException $e) {
// fix the payload
} catch (ApiException $e) {
match ($e->getApiCode()) {
'MESSAGE_LIMIT_REACHED', 'STORAGE_LIMIT_REACHED' => notifyAdminToUpgrade($e),
'ACTION_FORBIDDEN' => logForbidden($e),
default => throw $e,
};
}
```

A worked example of catching every variant lives in
[`examples/error_handling.php`](examples/error_handling.php).

## Known limitations

- The SDK returns only the `data` part of the API response envelope. The
`meta` part (e.g. `meta.warnings`, `meta.updated`) is dropped and is not
reachable through the SDK. If you need it, call the API directly or wrap
your own `HttpClientInterface` to inspect the raw response.

## Requirements

- PHP **8.3** or **8.4**
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
],
"ca": [
"@phpstan",
"@cs-check"
"@cs"
]
},
"autoload": {
Expand Down
4 changes: 3 additions & 1 deletion 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. |
| [`paginate_messages.php`](paginate_messages.php) | Walk every message in a thread via the `lastMessageHash` cursor. |
| [`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) | Incremental sync of new messages via the inclusive `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,
);
}
Loading
Loading