messaging
Tier 1 · engines. No titan dependencies. zod v4 is a peer.
npm install @titan-design/messaging zodThe problem it solves
Sending a person a text message drags a vendor into your codebase. A BlueBubbles chat GUID, a Telegram chat id, two different error envelopes, two different webhook-authentication schemes, and a parse_mode that silently mangles an apostrophe. Every layer above — the scheduler, the protocol state machine, the composer — ends up knowing which one you picked.
The seam here is one method wide:
interface MessageTransport {
send(input: { handle: string; text: string }): Promise<SendResult>;
}Everything above that seam sees a handle, a string, and a typed failure. The core is fetch-only — no node: imports anywhere — so the same build runs in a Cloudflare Worker, in a daemon, and under vitest.
When to reach for it
You want to text a human from a program, and you would rather not care whether it goes out over iMessage or Telegram. Or you want just the inbound half: validateInbound and validateTelegramWebhook are pure functions you can mount in any HTTP handler.
Example
Verified against 0.1.0.
MockTransport implements the whole contract in memory, so a consumer's retry and ceiling logic is testable with no Apple ID, no second macOS user, and no server:
import { MockTransport } from "@titan-design/messaging";
const transport = new MockTransport();
transport.failNext({ kind: "unreachable", message: "dark" });
await transport.send({ handle: "+15550000000", text: "one" });
// { ok: false, error: { kind: 'unreachable', message: 'dark' } }
await transport.send({ handle: "+15550000000", text: "two" });
// { ok: true, messageGuid: 'mock-2' }
transport.sent.length; // 2 — a scripted failure is still a recorded attemptIt implements InteractiveTransport as well: log is every call in order with the time it happened, messageAt(ref) is what the person would see now, typingVisible(handle) is true for five seconds on the injected clock or until the next send, and capabilities takes a partial override so each degrade path is testable. failNext(error, on?) scopes a scripted failure to one method. ManualClock is a Scheduler whose time moves only on advance(ms), and fakeTextUpdate / fakeCallbackUpdate build raw Bot API JSON so a door under test still runs the real parser.
Send
BlueBubblesTransport talks to a BlueBubbles Server over its REST API. iMessage cannot open a conversation from an API call, so the chat GUID is resolved by the caller and a handle with no existing chat is a typed failure, not an exception.
import { createBlueBubblesTransport } from "@titan-design/messaging";
const transport = createBlueBubblesTransport({
baseUrl: "http://127.0.0.1:1234",
password: config.bluebubblesPassword, // never read from env by this package
chatGuidFor: (handle) => chats.get(handle),
});
const result = await transport.send({ handle: "+15550000000", text: "sunday?" });
if (!result.ok) {
switch (result.error.kind) {
case "no-chat": // a human must send the first message to this handle
case "unreachable": // provably never sent; the one kind safe to retry as-is
case "indeterminate": // may have been delivered; never resend automatically
case "unauthorized": // wrong server password
case "too-long": // carries limit and length, so a composer can split
case "rejected": // 4xx, result.error.message is the server's own text
case "rate-limited": // 429; retryAfterSeconds when the server named a wait
case "unknown": // 5xx, or a chatGuidFor that threw
}
}ok: true means the server accepted the message for sending, and nothing more. Errors — including the ones fetch itself raises with the URL inside them — are redacted before they leave the transport, so the server password never reaches a log line, a thrown error, or a result object.
Message identity
A successful send carries a MessageRef: { channel, chat, messageId, threadId? }. It is plain JSON a product stores as is and hands back to address a later edit or reaction. The pair (chat, messageId) is the identity, because a Telegram message_id is unique only within one chat, and the ref holds the resolved chat, so an edit cannot land elsewhere if the handle map changed. This package stores nothing itself; the ref is the key a product's own message store should use. refOfInbound(update) builds the same ref for an inbound update.
Acknowledging a message
MessageTransport stays one method wide. Everything that acts on a message that already exists lives on InteractiveTransport, which extends it, so a consumer's own one-method fake still satisfies the send contract. Both adapters implement it.
if (transport.capabilities.reactions) {
await transport.react({ to: ref, emoji: "👀" }); // null clears the mark
}
await transport.edit({ ref, text: "Logged", buttons: "remove" });No method throws. Each answers { ok: true, changed } or { ok: false, error }, where error is a SendError plus unsupported (naming the capability the channel lacks) and message-gone. capabilities is a static descriptor: channel, maxTextLength, canInitiate, deliveryCeiling, buttons, buttonStates, edits, reactions, chatActions, drafts, draftStreaming and threads. A flag is true only where the shipped adapter implements the thing today, so it is safe to branch on before a call.
An edit with neither text nor buttons names nothing to change, so it fails bad-buttons before any call; the Mock adapter answers the same way.
Inbound
BlueBubbles publishes no request-signature scheme. The boundary is therefore a secret path segment, a sender allowlist, a GUID dedupe and a length cap, and validateInbound applies all four. The secret is compared in constant time. Dedupe storage is injected, so the validator needs no database and stays runtime-neutral.
import { MemorySeenStore, validateInbound } from "@titan-design/messaging";
const seen = new MemorySeenStore(); // swap for a KV- or SQLite-backed SeenStore
const result = await validateInbound({
pathSecret: url.pathname.split("/").pop() ?? "",
expectedSecret: env.IMESSAGE_WEBHOOK_SECRET,
allowedHandles: ["lifter@icloud.com"],
body: await request.json(),
maxTextLength: 2000,
seen,
});
if (result.status === "accepted") {
await enqueue({ handle: result.handle, text: result.text, guid: result.guid });
}A rejection carries exactly one reason: bad-secret, malformed, from-me, sender-not-allowed, too-long or duplicate. SeenStore { has(guid), add(guid) } accepts sync or async implementations; only the in-memory one ships here.
Liveness
macOS auto-logs in exactly one user at boot, so after any reboot or OS update the sending session is dark until a human fast-user-switches into it. Dark is an expected state, not an exception, and a consumer should probe before it sends.
import { probeLiveness } from "@titan-design/messaging";
const liveness = await probeLiveness(config, { timeoutMs: 2000 });
if (liveness.state === "dark") {
alertHuman(liveness.reason); // unreachable | unauthorized | timeout | bad-response
}alive carries serverVersion, osVersion and privateApi when the server reports them. The probe races its own timer and aborts the request it gave up on, so a hung server cannot wedge a caller's tick.
Splitting long text
splitText breaks a reply into parts that each fit a channel's limit. It prefers paragraph, newline, sentence and whitespace boundaries in that order, never splits a surrogate pair, and closes then reopens a code fence that straddles a break.
import { splitText, TELEGRAM_MAX_TEXT_LENGTH } from "@titan-design/messaging";
for (const part of splitText(reply, { maxLength: TELEGRAM_MAX_TEXT_LENGTH })) {
await transport.send({ handle, text: part });
}Joining the parts reproduces the input, except for the fence lines added at a break.
Backtick and tilde fences are recognised, following CommonMark; indented code blocks are not.
When a split lands just before a run of three backticks at the end of a code line, the next part can contain a line that is only a fence marker. The splitter keeps the fence open, but a Markdown renderer reading that part alone may read it as a closer.
Telegram
TelegramTransport implements the same MessageTransport over the Bot API. A bot cannot open a conversation either: the person messages the bot first, and the chat id is resolved by the caller exactly like the BlueBubbles chat GUID. The token sits in the URL path (/bot<token>/sendMessage), so every error is redacted on the way out.
import { createTelegramTransport } from "@titan-design/messaging";
const transport = createTelegramTransport({
token: config.telegramBotToken, // never read from env by this package
chatIdFor: (handle) => chatIds.get(handle),
});
const result = await transport.send({ handle: "lifter", text: "sunday?" });
if (!result.ok && result.error.kind === "too-long") {
split(result.error.length, result.error.limit); // TELEGRAM_MAX_TEXT_LENGTH, 4096
}SendInput.buttons is optional and channel-neutral: rows of { label, data }. Telegram renders them as an inline keyboard with data as callback_data; BlueBubbles ignores them, because iMessage has no keyboards. Telegram caps callback_data at 64 UTF-8 bytes (TELEGRAM_MAX_CALLBACK_DATA_BYTES), so longer data or an empty label fails with bad-buttons before any call. The consumer owns the data format; relay uses v1|<action>|<slot>|<date>.
await transport.send({
handle: "lifter",
text: "Lunch?",
buttons: [[
{ label: "Ate", data: "v1|ate|lunch|2026-09-18" },
{ label: "Skipped", data: "v1|skip|lunch|2026-09-18" },
]],
});Inbound arrives either way Telegram offers. pollUpdates is the long poll a daemon runs. It tracks the offset (last update_id + 1), acknowledges every update it saw, yields text messages and button taps from an allowed chat, and returns when the signal aborts. Each item is a TelegramInbound, discriminated on kind: "text" carries text, and "callback" carries data, callbackQueryId and the messageId the button sat on. Both kinds carry messageId and an optional threadId; a typed update adds replyToMessageId, and a tap adds messageText and buttons — the tapped prompt's own keyboard, minus any button without callback_data, so a receipt needs no store of what was sent.
import { pollUpdates, readChatIds } from "@titan-design/messaging";
const chatIds = await readChatIds(config); // one-time: "what is my chat id?"
for await (const update of pollUpdates(config, {
timeoutSeconds: 30,
allowedChatIds: [chatIds[0]],
signal: controller.signal,
})) {
if (update.kind === "text") {
await enqueue({ chatId: update.chatId, text: update.text });
} else {
await record(update.data);
await answerCallbackQuery(config, update.callbackQueryId, "Logged");
}
}Answer every tap with answerCallbackQuery(config, callbackQueryId, text?), or the button keeps spinning on the phone. It never throws; a failure comes back as { ok: false, reason } with the token redacted.
validateTelegramWebhook is the push equivalent, over the X-Telegram-Bot-Api-Secret-Token header that Telegram echoes from setWebhook: the same constant-time compare, a chat allowlist, a length cap on text, and an update_id dedupe through the same SeenStore. It reads the same two shapes as pollUpdates and returns the same TelegramInbound on accept. Its rejection reasons are bad-secret, malformed, not-text, sender-not-allowed, too-long and duplicate.
probeTelegramLiveness(config, { timeoutMs }) calls getMe, the documented way to test a token, so one probe answers both "does the API answer" and "does this token still work". It returns the same alive / dark shape as probeLiveness, with the bot username on alive.
What it deliberately does not do
- Secrets. The server password, the bot token and the webhook secret are configuration values the caller passes in. Nothing here reads an environment variable or a file.
- Persistence. The dedupe is an injected
SeenStore. Keeping it out means no tier-0 dependency, so the validator runs unchanged in a Worker. - Delivery and read signals. iMessage read receipts, typing indicators and tapbacks all need the SIP-disabled helper bundle. The Bot API has no delivery or read receipt for bots at all. The contract exposes neither, so a state machine above it cannot branch on one.
- Scheduling. No cron, no queue, no quiet hours, no retry loop. The transport reports one typed failure per attempt; when to try again is the consumer's policy.
- Opening conversations.
chatGuidForandchatIdForare parameters because the identifier only exists once a human has sent the first message.
Gotchas
indeterminate is not an error to retry. A socket reset, a timeout awaiting the response, an unrecognised error shape, or a 2xx with an unreadable body can all follow a delivered message, so resending can deliver it twice. Only unreachable (and a 429 after its backoff) is safe to resend automatically. Neither backend has a dedupe key that changes this: BlueBubbles forgets a tempGuid once its send settles, and Telegram has none. The package README's "Retry semantics" section has the full table.
A 429 is rate-limited, not rejected. It carries retryAfterSeconds when the server named a wait: parameters.retry_after on Telegram, or the "retry after N" text of the description when the envelope omits it. A retry_after of zero, negative, non-numeric, NaN or infinite is treated as absent, so the description fallback applies; a fraction rounds up to a whole second, and a value above TELEGRAM_MAX_RETRY_AFTER_SECONDS (86,400, one day) is clamped to it. The description fallback follows the same rules. When neither names a wait the field is absent and the consumer picks its own backoff, because the package never invents a number. BlueBubbles never names one.
An unchanged edit is a success, not an error. Telegram answers 400 "message is not modified" when an edit changes nothing, and edit maps that to { ok: true, changed: false }. That is what makes a repeat tap or an at-least-once retry a quiet no-op.
Button state and style are off on Telegram by default. The Bot API added style in 9.4 and disabled in 10.3, but neither wire shape has been confirmed against a live bot. The types and the rendering ship behind buttonStates, which a spike turns on through TelegramConfig.
no-chat is not an error to retry. It means no conversation exists yet, and no number of retries will create one. A human has to send the first message. Both adapters return it: BlueBubbles when chatGuidFor yields nothing, Telegram on a 400 whose description reads "chat not found".
too-long is checked before the call, not after. That is why it is its own SendError kind rather than a rejected with a status: a composer should split the text, not back off.
A tap without data or message is skipped, not yielded. Telegram omits message for a button on an inline-mode message and omits data for a game button. Neither can be routed, so the webhook rejects both as not-text.
A rejected inbound GUID is never recorded. A genuine retry of a rejected request is still judged on its merits, so a transient allowlist misconfiguration does not permanently swallow a message.
pollUpdates acknowledges what it skips. A photo, an edit, a stranger, or a shape this package does not read all advance the offset and are dropped rather than thrown. Long polling would otherwise stall forever on one unreadable update.
readChatIds passes no offset on purpose. It confirms nothing, so a daemon polling the same bot still sees those updates. It is the human's one-time setup call, not a consumer API.
Where it came from
TP-59, for a coaching product that texts a lifter over iMessage. TP-60 added the Telegram adapter, which is what proved the contract was actually vendor-neutral rather than BlueBubbles with extra steps.