Messenger

Sending the core's messages through a connector — send, edit, delete and sendFile, the Message and Recipient objects, and everything the core checks before your port is called.

Subscriby\Connector\Contracts\Ports\Messenger. Bound by messaging; broadcasts adds bulk sends on the same class.

The core composes every message once and reads the result rather than trusting an HTTP status, because platforms refuse with 200 OK as often as with an exception. Your Messenger renders nothing: it lays the body your TextRenderer produced and the actions out in the platform's shape and reports what happened.

Methods

MethodCalled whenReturns
send(installation, credentials, Recipient, Message)Every confirmation, reminder, alert, sale notice, pass notice and broadcast the core delivers to one person.DeliveryResult
sendFile(installation, credentials, recipient, url, ?caption)A file the core has to hand a person: an access-code export, a receipt. Refused by the core when the manifest says supports_files: false.DeliveryResult

The message

new Message(
    body: '<b>Payment received.</b> Your access to <i>Signals</i> is active.',
    actions: [
        MessageAction::url('Open the channel', 'https://…'),
        MessageAction::command('Manage plan', ManagementCommand::PlanManage, ['plan' => $id]),
    ],
    meta: ['example' => ['silent' => true]],
);
  • body is the canonical HTML subset; hand it to your TextRenderer.
  • actions are built through four factories only: url(label, url), callback(label, data), copy(label, text) and command(label, command, params). A command button names a catalogue command in the core's words and each connector renders it into the callback its own surface answers to, so no core message spells a platform's routing. MessageAction::assertWithin() has already thrown on the developer's machine for a callback the platform would truncate.
  • meta is keyed by connector: $message->metaFor('example') gives you your own extras and every other connector ignores them.

The recipient is a Recipient: the IdentityRef to reach and an optional threadId when the platform threads conversations.

What the core has done before calling you

  1. The capability switch. A creator may switch messaging or broadcasts off for one installation; the core answers a Configuration failure itself ("The messaging capability is switched off for this installation") and never calls the port.
  2. The limits. A body over max_length has been cut into parts, each within the limit: the core splits at the last paragraph break that fits, then the last line break, then the last space, never inside a tag or an entity, and closes and reopens the tags open at a cut so every part is well-formed canonical HTML. Your send() receives the parts in order, one call each, and the buttons arrive with the last part; the core reports the last part's result, or the first failure, to the caller. Message::assertWithin($manifest->messaging) has run on every part; a message with too many buttons or too many per row, or a limit too short to carry a word beside the message's own markup, becomes a Configuration failure before your port.
  3. Files. sendFile() is refused by the core with Configuration when the manifest says supports_files: false.
  4. Reach. The core chose which account to write to, by the notice's intent: a grant notice goes to the connector of the grant only, a support reply to the connector the member wrote from, and every other notice to the member's preferred account and the accounts they switched notices on for; an account your IdentityResolver::deliveryTarget() answered null for is passed over for the next, and email carries an account-critical notice once no account did. A creator's alerts follow their alert destinations. You are never asked which account; you are handed one.
  5. Pacing. Bulk sends are spaced by pacing; the broadcast pipeline reads min_interval_microseconds, jobs that send several messages to one person sleep per_recipient_interval_microseconds.
  6. Accounting. Every call is timed and counted per connector and operation, and its DeliveryResult is read: a failure of a retryable kind requeues the job, a creator-actionable kind becomes a health verdict, anything else is recorded as failed.

What you return

DeliveryResult::delivered($platformMessageId);
DeliveryResult::failed($this->failures->classify($response));

externalMessageId is what edit() and delete() will be given back, so return the platform's real id. Build every failure through your FailureClassifier; never throw for a platform refusal.

How Telegram does it

The canonical subset is Telegram's own parse mode, so the body goes as written; the actions become one inline button per row, the layout every core message has always used; callback data crosses verbatim as the key:value pairs the bot's handler already parses, and a command button is rendered into those pairs by the connector's CommandCallbacks. The result is read from the response body, because Telegram refuses inside a 200 OK.

What the kit checks

No kit rule drives Messenger directly, because a send needs a platform. Subscriby's suite sends every core message class through the fake connector's FakeMessenger, which records what it was asked and asserts with assertSentTo($externalId, $containing), assertNotSentTo($externalId, $containing) and assertNothingSent(); use the same fake to test the core paths your connector relies on, and your platform's fake client to test the port itself.

Silence is a failure

A Messenger that swallows a refusal and returns delivered teaches the core that a member was told something they never received. Classify and return; the core knows what to do with each kind.

How is this guide?

On this page

Subscriby is a product designed by you — for you.

No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request