5. Messages
Implement TextRenderer, Messenger and FailureClassifier for Agora — canonical HTML to BBCode, private messages without buttons, reply keywords for actions, and a classifier table for the forum's refusals.
Every confirmation, reminder and alert the core composes reaches a member as an Agora private message. Three ports make that work: the renderer turns the core's HTML into BBCode, the messenger sends it, the classifier reads what came back.
The renderer
Agora formats with BBCode. The eight canonical tags map one to one:
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora\Ports;
use Subscriby\Connector\Contracts\Ports\TextRenderer;
final class AgoraTextRenderer implements TextRenderer
{
private const array TAGS = [
'<b>' => '[b]', '</b>' => '[/b]',
'<i>' => '[i]', '</i>' => '[/i]',
'<u>' => '[u]', '</u>' => '[/u]',
'<s>' => '[s]', '</s>' => '[/s]',
'<code>' => '[code]', '</code>' => '[/code]',
'<pre>' => '[code]', '</pre>' => '[/code]',
'<blockquote>' => '[quote]', '</blockquote>' => '[/quote]',
];
public function render(string $canonicalHtml): string
{
$rendered = preg_replace_callback(
'#<a href="([^"]+)">(.*?)</a>#s',
static fn (array $match): string => '[url='.$match[1].']'.$match[2].'[/url]',
$canonicalHtml,
) ?? $canonicalHtml;
return html_entity_decode(strtr($rendered, self::TAGS), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}Plain text survives untouched (strtr finds nothing, html_entity_decode changes nothing), every word inside a tag survives, and entities the core encoded (&) become the characters BBCode expects. The kit's two text.* rules pass.
The messenger
Agora private messages have no buttons, and the core may attach up to three actions (the manifest said so). A URL action becomes a BBCode link; a callback or command action becomes a reply keyword the member types back, which chapter 4's dispatcher maps to the stored action.
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora\Ports;
use Acme\Connectors\Agora\Agora;
use Acme\Connectors\Agora\Models\Prompt;
use Subscriby\Connector\Contracts\Ports\Messenger;
use Subscriby\Connector\Data\CredentialBag;
use Subscriby\Connector\Data\DeliveryFailure;
use Subscriby\Connector\Data\DeliveryResult;
use Subscriby\Connector\Data\InstallationRef;
use Subscriby\Connector\Data\Message;
use Subscriby\Connector\Data\MessageAction;
use Subscriby\Connector\Data\Recipient;
use Subscriby\Connector\Enums\DeliveryFailureKind;
use Subscriby\Connector\Enums\MessageActionKind;
final class AgoraMessenger implements Messenger
{
public function __construct(
private readonly Agora $agora,
private readonly AgoraTextRenderer $renderer,
private readonly AgoraFailureClassifier $failures,
) {}
public function send(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, Message $message): DeliveryResult
{
$response = $this->agora->for($installation, $credentials)->post('/messages', [
'to' => $recipient->identity->externalId,
'body' => $this->body($message),
]);
if ($this->agora->refused($response)) {
return DeliveryResult::failed($this->failures->classify($response));
}
$messageId = (string) $response->json('data.id');
$this->rememberKeywords($installation, $recipient, $messageId, $message->actions);
return DeliveryResult::delivered($messageId);
}
public function edit(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $externalMessageId, Message $message): DeliveryResult
{
$response = $this->agora->for($installation, $credentials)->patch('/messages/'.$externalMessageId, ['body' => $this->body($message)]);
return $this->agora->refused($response) ? DeliveryResult::failed($this->failures->classify($response)) : DeliveryResult::delivered($externalMessageId);
}
public function delete(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $externalMessageId): DeliveryResult
{
$response = $this->agora->for($installation, $credentials)->delete('/messages/'.$externalMessageId);
return $this->agora->refused($response) ? DeliveryResult::failed($this->failures->classify($response)) : DeliveryResult::delivered($externalMessageId);
}
public function sendFile(InstallationRef $installation, CredentialBag $credentials, Recipient $recipient, string $url, ?string $caption = null): DeliveryResult
{
return DeliveryResult::failed(new DeliveryFailure(DeliveryFailureKind::Configuration, 'Agora private messages cannot carry files.'));
}
private function body(Message $message): string
{
$lines = [$this->renderer->render($message->body)];
$keyword = 0;
foreach ($message->actions as $action) {
$lines[] = match ($action->kind) {
MessageActionKind::Url => '[url='.$action->value.']'.$action->label.'[/url]',
MessageActionKind::Copy => $action->label.': [code]'.$action->value.'[/code]',
MessageActionKind::Callback, MessageActionKind::Command => 'Reply [b]'.(++$keyword).'[/b] to '.lcfirst($action->label).'.',
};
}
return implode("\n\n", $lines);
}
/**
* @param list<MessageAction> $actions
*/
private function rememberKeywords(InstallationRef $installation, Recipient $recipient, string $messageId, array $actions): void
{
$keyword = 0;
foreach ($actions as $action) {
if ($action->kind === MessageActionKind::Callback || $action->kind === MessageActionKind::Command) {
Prompt::query()->create([
'installation_id' => $installation->id,
'identity_external_id' => $recipient->identity->externalId,
'message_id' => $messageId,
'keyword' => (string) ++$keyword,
'kind' => $action->kind->value,
'value' => $action->value,
'params' => $action->params,
]);
}
}
}
}agora_prompts is a second table of ours: the keyword a member may reply with, what it stood for, and which message it belonged to. When message.created arrives with a body of 1 from a member who has an open prompt, the dispatcher looks the prompt up and acts as if the button had been tapped: a Command prompt names a ManagementCommand or MemberCommand with its params, a Callback prompt carries the core's data verbatim. That is the whole trick of a platform without buttons.
Note what the messenger does not do: it never checks the message's length (the core did, against the manifest), never decides who to write to (the core resolved the recipient), never retries (the core reads the failure kind), and always builds its failure through the classifier. sendFile() is still implemented, because the port requires it, but the core never calls it while the manifest says supports_files: false.
The classifier
Agora refuses in two shapes: an HTTP error, or 200 OK with "ok": false and an error code. Both go through one table:
<?php
declare(strict_types=1);
namespace Acme\Connectors\Agora\Ports;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Subscriby\Connector\Contracts\Ports\FailureClassifier;
use Subscriby\Connector\Data\DeliveryFailure;
use Subscriby\Connector\Enums\DeliveryFailureKind;
use Throwable;
final class AgoraFailureClassifier implements FailureClassifier
{
private const array CODES = [
'invalid_key' => DeliveryFailureKind::Configuration,
'key_revoked' => DeliveryFailureKind::Configuration,
'forbidden' => DeliveryFailureKind::NotPermitted,
'not_a_moderator' => DeliveryFailureKind::NotPermitted,
'board_not_found' => DeliveryFailureKind::TargetMissing,
'user_not_found' => DeliveryFailureKind::TargetMissing,
'message_not_found' => DeliveryFailureKind::TargetMissing,
'messages_disabled' => DeliveryFailureKind::Unreachable,
'user_banned' => DeliveryFailureKind::Unreachable,
'rate_limited' => DeliveryFailureKind::RateLimited,
];
public function classify(mixed $responseOrThrowable): DeliveryFailure
{
if ($responseOrThrowable instanceof ConnectionException) {
return new DeliveryFailure(DeliveryFailureKind::Transient, $responseOrThrowable->getMessage());
}
if ($responseOrThrowable instanceof Throwable) {
return new DeliveryFailure(DeliveryFailureKind::Other, $responseOrThrowable->getMessage());
}
if ($responseOrThrowable instanceof Response) {
$code = (string) $responseOrThrowable->json('error', $responseOrThrowable->status() >= 500 ? 'server_error' : 'unknown');
$kind = self::CODES[$code] ?? ($responseOrThrowable->status() >= 500 ? DeliveryFailureKind::Transient : DeliveryFailureKind::Other);
return new DeliveryFailure(
$kind,
(string) $responseOrThrowable->json('message', $responseOrThrowable->reason()),
$kind === DeliveryFailureKind::RateLimited ? (int) $responseOrThrowable->header('Retry-After', '60') : null,
$code,
);
}
return new DeliveryFailure(DeliveryFailureKind::Other, is_scalar($responseOrThrowable) ? (string) $responseOrThrowable : 'An unknown refusal.');
}
}The last return is what the kit's failures.classifies_anything needs: a string classifies to Other rather than throwing. A network exception is Transient and the core retries; a 5xx without a code is Transient too; a banned user or a member who switched private messages off is Unreachable, and the core stops writing to that identity; a revoked key is Configuration, and the core marks the installation for the creator.
Test it
it('renders the canonical subset to BBCode and leaves plain text alone', function (): void {
$renderer = new AgoraTextRenderer;
expect($renderer->render('Hello, world'))->toBe('Hello, world')
->and($renderer->render('<b>Paid</b> & <a href="https://x.test">open</a>'))->toBe('[b]Paid[/b] & [url=https://x.test]open[/url]');
});
it('turns actions into a link and reply keywords and remembers the keywords', function (): void {
Http::fake(['forum.example.com/api/messages' => Http::response(['ok' => true, 'data' => ['id' => 'm_9']])]);
$result = $messenger->send($installation, $credentials, new Recipient($member), new Message('<b>Welcome.</b>', [
MessageAction::url('Open the board', 'https://forum.example.com/b/12'),
MessageAction::command('Refresh my access', MemberCommand::ReissueGrants),
]));
expect($result->delivered)->toBeTrue()->and($result->externalMessageId)->toBe('m_9');
Http::assertSent(fn (Request $sent): bool => str_contains($sent['body'], "[url=https://forum.example.com/b/12]Open the board[/url]\n\nReply [b]1[/b] to refresh my access."));
expect(Prompt::query()->where('message_id', 'm_9')->where('keyword', '1')->value('value'))->toBe('reissue_grants');
});
it('classifies the forum\'s refusals', function (): void {
$classifier = new AgoraFailureClassifier;
expect($classifier->classify(new Response(new Psr7Response(200, [], '{"ok":false,"error":"user_banned"}')))->kind)->toBe(DeliveryFailureKind::Unreachable)
->and($classifier->classify(new Response(new Psr7Response(429, ['Retry-After' => '30'], '{"error":"rate_limited"}')))->retryAfterSeconds)->toBe(30)
->and($classifier->classify('anything'))->kind->toBe(DeliveryFailureKind::Other);
});Bind the capability
Add Messenger::class to AgoraConnector::register() now: the manifest declares messaging, and the registry refuses the package at boot until the port is bound.
How is this guide?
4. Webhooks and identities
Implement InboundGateway and IdentityResolver for Agora — verify the HMAC signature, decode events into envelopes with stable idempotency keys, resolve the actor, and write the route the core's gate protects.
6. Boards and access
Implement SpaceCatalog and AccessController for Agora — let a creator pick a board, record it as a space, add and remove members as grants, announce access, reconcile, and mark the one step the SDK cannot finish yet.