Support

The support inbox as a connector feeds it and answers from it — filing a member's message, sending the acknowledgement the core says is due, handing the core a creator's answer written on the platform, finding the thread a relay message or a relay-space thread belongs to, and recording the space a project relays into.

Subscriby\Connector\Core\Support. The inbox is the core's: it opens one thread per member per connector, keeps the messages, throttles a flood, drops a blocked member's words, shows the thread in the dashboard and announces it to webhooks. A connector is the way words enter and leave. Inbound, it hands the core what a member wrote in the installation's private conversation and sends back the project's acknowledgement. Outbound, when the creator answers on the platform rather than in the dashboard (a reply typed into the relay's ping, or written inside the thread the relay opened in a group), it hands that answer to the core too, so every reply is recorded, delivered and announced the same way whatever surface it came from. The SupportRelay port is the other direction: the core calling you to carry a reply or a thread to the platform.

Filing a member's message

$installation = $bot->installationRef();

if (! $this->support->acceptsMessages($installation)) {
    return false;                                  // support is off: fall through to "I did not understand"
}

$message = $this->mapper->map($update);            // an InboundSupportMessage

if ($message->isEmpty()) {
    return false;
}

$ingested = $this->support->ingest($installation, $chat->identityRecord($displayName), $message);

if ($ingested?->autoReply !== null) {
    $this->send($chat, $ingested->autoReply);      // the project's acknowledgement, yours to render
}
MethodReturnsNotes
acceptsMessages(InstallationRef $installation)boolWhether the project behind the installation takes support messages. Ask first, so a message the project does not want goes to your own fallback instead of the inbox. False for a platform-wide installation.
ingest(InstallationRef, IdentityRecord $author, InboundSupportMessage)?IngestedSupportMessageRecords the account (idempotent on the platform's id), finds the member who holds it in the project or creates them as a lead, opens or reopens their thread on your connector, records the message. Null when the core dropped it on purpose: support off, the member blocked, the member over the inbound allowance, nothing to file.

IngestedSupportMessage carries the SupportMessageRef (id, conversationId, projectId) and autoReply: the project's acknowledgement when one is due (the first message of a thread, by the core's rules), or null. The connector renders and sends it; the core never writes to a platform on its own.

Edits and replays. A message the platform already delivered is matched by externalId and externalChatId and updated in place, so an edit and a replayed webhook both leave one row. Pass both on every inbound message.

InboundSupportMessage

The same shape carries a member's question in and a creator's answer back. kind is a SupportMessageKind (text, photo, video, audio, voice, document, sticker, animation, location, contact); a captioned photo is a photo whose body is the caption. attachments is a list of InboundSupportAttachment: the kind, the platformFileId (the platform's own reference; the core fetches the bytes through SupportRelay::fetchAttachment() the first time a creator opens the thread, never here), and whatever the platform said about it (mime, fileName, size, width, height, duration, thumbnailFileId, and meta for a sticker's emoji, an audio title, coordinates, a contact's name and number). externalId, externalChatId and quotedExternalId are the platform's ids; meta keeps anything else (an album id, an edit flag). InboundSupportMessage::text($body) builds a plain text, and isEmpty() says whether there is anything to file.

A creator's answer written on the platform

$conversation = $this->support->conversationForRelayMessage((string) $chat->chat_id, (string) $quotedMessageId);

if ($conversation === null) {
    return false;                                  // not one of the core's relay pings
}

try {
    $this->support->reply($conversation->ref(), new CreatorRef($creator->getKey()), InboundSupportMessage::text($text), SupportReplySource::DirectMessage);
} catch (SupportRefused) {
    return false;
}

$this->send($chat, __('✅ Sent to :name.', ['name' => $conversation->memberName]));
MethodReturnsNotes
reply(ConversationRef, ?CreatorRef $author, InboundSupportMessage, SupportReplySource)SupportMessageRefRecords the answer and queues its delivery to the member on the connector their thread is on, through your SupportRelay. The author is checked as the dashboard checks a reply (the owner, or a member of the team); null records it as the owner's, for a message written somewhere only the creator's staff can write.
conversationForReply(string $conversationId, CreatorRef $author)?SupportConversationSummaryThe thread a creator chose (a Reply button on your relay ping carries its id), or null when it is gone or this creator may not answer it. Ask before parking the id and prompting for words.
conversationForRelayMessage(string $externalChatId, string $externalMessageId)?SupportConversationSummaryThe thread a relay message announced. The core remembers the platform id of every relay message it sent for a month, so a native "reply to message" needs no button and no parked state.
conversationForThread(InstallationRef, string $threadId)?SupportConversationSummaryThe thread mirrored into one thread of the project's relay space, by the id openThread() answered with.

SupportReplySource says where on your connector the creator wrote: DirectMessage (typed into the private conversation your relay pinged them in) or RelaySpace (written inside the thread your relay opened in a shared space). The inbox stores it as connector_relay_dm or connector_relay_group on the message's source.

SupportConversationSummary is the thread as you may read it back: id, projectId, connector, memberName (for your own reply, "Sent to Ada."), and ref() for the writes. The messages, the status moves and the creator's notes stay the core's.

Who may answer is the core's call

Do not check permissions yourself. reply() with a CreatorRef refuses anyone who is neither the project's owner nor on its team; conversationForReply() answers null for the same people. When a message arrives from a place you have verified is the project's linked relay space, pass null as the author: the group is the creator's own staff room, and the answer is recorded as the owner's.

The relay space

A project that relays into a shared space (a topic per member in a forum group, a thread per member in a channel) has linked one place. The core keeps it; you tell the core when the platform has made the installation able to write there, and read it back to recognise messages that come from it.

MethodReturnsNotes
relayTarget(InstallationRef)?SupportRelayTargetThe project's relay mode, in the words your manifest declares under relay_modes (or none), and the linked space when the mode uses one. isSpace($externalId) compares a place's platform id. Null for an installation that names no project.
linkRelaySpace(InstallationRef, SpaceRef $space)voidRecords the place. Called when the platform made the installation an administrator there, never from a typed id; authorised as the project's settings are, with the owner acting.
unlinkRelaySpace(InstallationRef)voidForgets it, once the installation can no longer write there.

The SpaceRef you pass is the one you recorded through Core\Spaces, or the stand-in ref of your own row while your rows still lead; the core keeps both pointers.

Refusals

Subscriby\Connector\Exceptions\SupportRefused, with a stable reason a connector can branch on and a message for the developer, never for a creator or a member:

reasonWhen
conversation_unknownreply() named a thread that does not exist.
not_permittedThe CreatorRef may not answer on the project, or nobody may act on it for a relay-space write.
installation_unknownThe installation names no project (unknown, or platform-wide).
project_unknownThe thread's project is gone.
space_unknownlinkRelaySpace() was given a ref that names no recorded space and no row of your own.

Every other refusal is a null read: ingest() answering null means the core chose to drop the message, and the conversationFor*() reads answer null for a thread that is gone or not yours to touch.

How Telegram does it

The project bot's last message stage asks acceptsMessages(), maps the update (photo, sticker, voice note, document, location, contact) into an InboundSupportMessage, files it with the chat's identityRecord() and the sender's name, and sends the autoReply as the bot's own message. On the platform bot, the relay ping's Reply button parks the thread conversationForReply() confirmed and the next typed message goes through reply() as a DirectMessage; a native reply to the ping is routed through conversationForRelayMessage() without any parked state. On a project bot administering a forum group, a message in a topic is matched through relayTarget()->isSpace() and conversationForThread(), attributed to the teammate Core\Identities::findCreator() names when conversationForReply() lets them answer, and to the owner otherwise; the group is linked and forgotten from the bot's my_chat_member updates through linkRelaySpace() and unlinkRelaySpace().

One connector, one channel per member

A thread is keyed on the project, the member and your connector key: a member who writes through two connectors has two threads, and each is answered on the connector it came in on. The channel field the REST API and the MCP tools show is that key.

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