Documentation
¶
Overview ¶
Package botplan defines the neutral message-plan model and the capability descriptor that per-platform renderers consult (the "capability-driven rendering" seam, layer 5 of the ToGethered conversation architecture).
An application composes a MessagePlan once — a platform-neutral statement of intent: some rich text, optionally a prompt (choose one of N), a URL action, a live-panel marker, a proactive-send spec, and/or a single image. It never mentions Telegram or WhatsApp. A per-platform Renderer then turns that plan into one or more botmsg.MessageFromBot values, degrading capabilities the target platform lacks (a WhatsApp button grid becomes reply-buttons, then a list, then a paged list; a live-panel edit becomes an append; an out-of-window proactive send becomes an approved template).
Two ideas keep platform conditionals out of the application:
The neutral types carry no HTML or markdown strings. Text is modelled as lines of typed spans (Rich) so each renderer can emit HTML (Telegram), WhatsApp markers, or plain text without the app choosing.
Descriptor records the facts a renderer genuinely consults — button ceilings, edit/delete support, markup dialect, window gating. Every field mirrors a record in can-i-use/capability-map.json (cited per field) so the descriptors can be CI-checked against the platform facts.
Renderers live in the adapter repos (bots-fw-telegram, bots-fw-whatsapp); this package owns only the neutral vocabulary and the interface they implement.
Index ¶
Constants ¶
const MaxChoiceTokenBytes = botstoken.MaxTokenBytes
MaxChoiceTokenBytes is the maximum byte length of a Choice.Token.
It matches botstoken.MaxTokenBytes (64), which is Telegram's callback_data ceiling (capability-map telegram/callback-query constraints.callbackDataBytes = 64). A token that fits 64 bytes also fits WhatsApp's 256-char interactive id (capability-map whatsapp/reply-buttons semanticsPayloadBytes = 256) and a wa.me "?text=" suffix, so 64 is the binding constraint for all platforms.
Variables ¶
var ErrInvalidPlan = fmt.Errorf("botplan: invalid message plan")
ErrInvalidPlan is the base error for a MessagePlan (or one of its parts) that violates a neutral-layer invariant. Callers can match it with errors.Is.
var ErrNoTemplateForPurpose = fmt.Errorf("botplan: no approved template for proactive purpose")
ErrNoTemplateForPurpose is returned by a renderer when a proactive plan must be delivered out of the platform's send window but no approved template exists for its ProactiveSpec.Purpose.
This is not a bug — it is a scenario-specific policy point (scenario-catalogue SYS-TPL-030: "degrade the scenario, not the product"). The renderer surfaces it typed so the caller can apply that scenario's declared fallback (hold until the window reopens, drop, defer to a digest, …) rather than the renderer guessing one.
var ErrTemplateMismatch = fmt.Errorf("botplan: plan does not match approved template shape")
ErrTemplateMismatch is returned by a renderer when an approved template exists for the purpose but its shape does not match the plan — for example the prompt's choices do not line up with the template's approved quick-reply labels, or the params do not cover the template's body placeholders. Quick- reply buttons on a WhatsApp template are fixed at approval time, not composed per send (capability-map whatsapp/template-buttons buttonsFixedAtApprovalNotPerSend), so a plan that assumes different buttons cannot be honoured and must fail loudly rather than silently drop the choices.
var ErrTokenTooLong = fmt.Errorf("%w: choice token exceeds 64 bytes", ErrInvalidPlan)
ErrTokenTooLong is returned when a Choice.Token exceeds MaxChoiceTokenBytes. It wraps ErrInvalidPlan, so errors.Is(err, ErrInvalidPlan) also holds — a too-long token is one kind of invalid plan.
var ErrUnsupportedTarget = fmt.Errorf("botplan: unsupported render target platform")
ErrUnsupportedTarget is returned by a renderer asked to render for a platform it does not implement.
Functions ¶
This section is empty.
Types ¶
type ActionPrompt ¶
ActionPrompt presents N choices and expects one back, each carrying ≤64 bytes of callback state (capability vocabulary: prompt-choices).
LayoutRows is a hint for how many rows to arrange the choices into on platforms that support a button grid (Telegram). It is advisory: WhatsApp has no grid, so its renderer ignores it (capability-map whatsapp/reply-buttons grid=false). Zero or negative means "let the renderer decide" (one button per row on Telegram).
func (ActionPrompt) Validate ¶
func (p ActionPrompt) Validate() error
Validate checks the prompt's invariants: at least one choice, every label non-empty, every token non-empty and ≤64 bytes.
It does NOT decode the token — a token is an opaque botstoken string here; the only structural rule the neutral layer enforces is the byte ceiling, because that is the cross-platform fact (see MaxChoiceTokenBytes).
type ChatType ¶
type ChatType int
ChatType distinguishes private chats from group chats, which changes rendering (e.g. group posts, mention semantics).
type Choice ¶
Choice is one selectable option in an ActionPrompt.
Label is what the user sees. Token is the botstoken-encoded callback state (verb/subject/args) that identifies the choice when tapped; it must be ≤64 bytes. A choice with an empty Token is rejected by Validate — a prompt whose taps carry no state is a bug, not a degradation.
type Descriptor ¶
type Descriptor struct {
// MaxPromptButtons is the largest number of choices that render as inline/
// reply buttons before the renderer must degrade to a list or pages.
// Telegram: no fixed small cap (inline grid) — set high. WhatsApp:
// capability-map whatsapp/reply-buttons constraints.maxButtons = 3.
MaxPromptButtons int
// MaxListRows is the largest number of choices that render as a single
// selectable list before paging is required. Telegram has no list message
// (buttons only) — 0 means "not applicable". WhatsApp: capability-map
// whatsapp/list-messages constraints.maxRowsAcrossAllSections = 10.
MaxListRows int
// MaxButtonLabelChars is the button/row label ceiling; longer labels are
// truncated. Telegram: no documented hard limit — 0 means "unbounded".
// WhatsApp: capability-map whatsapp/reply-buttons buttonLabelMaxChars = 20.
MaxButtonLabelChars int
// SupportsEdit reports whether the platform can update a message in place
// (LivePanel). Telegram: capability-map telegram/edit-message native = true.
// WhatsApp: capability-map whatsapp/edit-message absent = false → append.
SupportsEdit bool
// SupportsDelete reports whether the platform can retract a message.
// Telegram: capability-map telegram/delete-message native = true. WhatsApp:
// capability-map whatsapp/delete-message absent = false.
SupportsDelete bool
// SupportsCallbackAck reports whether a tap can be acknowledged (toast /
// spinner-stop). Telegram: capability-map telegram/callback-query
// semanticsRequiresAck = true. WhatsApp: capability-map whatsapp/callback-ack
// absent = false — there is nothing to ack.
SupportsCallbackAck bool
// WindowGated reports whether proactive sends are gated by a service window
// that may force a template. Telegram: false (capability-map
// telegram/proactive-messaging native, no window). WhatsApp: true
// (capability-map whatsapp/customer-service-window partial, 24h).
WindowGated bool
// SupportsInlineURLButton reports whether a fully dynamic URL button can ride
// a normal (non-template) message. Telegram: capability-map
// telegram/inline-keyboard (URL buttons) = true. WhatsApp: capability-map
// whatsapp/cta-url-button native = true, but only in-window — the renderer
// combines this flag with RenderTarget.WindowOpen.
SupportsInlineURLButton bool
// SupportsButtonGrid reports whether buttons arrange in a multi-column grid
// (so ActionPrompt.LayoutRows is meaningful). Telegram: capability-map
// telegram/inline-keyboard constraints.layout = "grid" = true. WhatsApp:
// capability-map whatsapp/reply-buttons grid = false.
SupportsButtonGrid bool
// SupportsMedia reports whether the platform can send an image. Telegram:
// capability-map telegram/send-photo native = true. WhatsApp: capability-map
// whatsapp/send-image native = true (though the client may not yet implement
// it — a platform fact, not a client fact).
SupportsMedia bool
// TextMarkup is the styling dialect the Rich renderer must emit. See
// TextMarkup. Telegram: MarkupHTML. WhatsApp: MarkupMarkers.
TextMarkup TextMarkup
// SupportsAnchorTextLinks reports whether a link span can render as anchor
// text over a hidden URL. Telegram: capability-map telegram/text-formatting
// hyperlinks = true. WhatsApp: capability-map whatsapp/text-formatting
// noAnchorTextLinks → false, so links render as "anchor: url".
SupportsAnchorTextLinks bool
}
Descriptor is the static per-platform capability fact-sheet a renderer consults. It is the F5 half of the seam: every field mirrors a record in can-i-use/capability-map.json (cited in the field's doc comment) so the descriptor a renderer ships can be CI-checked against the platform facts.
It carries only the facts the two pilot renderers genuinely consult when shaping a MessagePlan — not the whole capability map. Dynamic facts (is the window open right now, is this template approved) are NOT here; they live on RenderTarget and in the template catalog, because they vary per send.
type LineKind ¶
type LineKind int
LineKind classifies a whole line of rich text.
const ( // LineParagraph is an ordinary text line. LineParagraph LineKind = iota // LineListItem is a bulleted list item. Renderers prefix it with the // platform's bullet marker ("• " for HTML, "* " for WhatsApp markers). LineListItem // LineQuote is a quoted line. Renderers emit <blockquote> (Telegram) or a // leading "> " (WhatsApp); plain text falls back to a leading "> ". LineQuote )
type LivePanel ¶
type LivePanel struct {
PanelKey string
}
LivePanel marks a plan as an update-in-place-if-possible of an earlier message (capability vocabulary: live-panel; capability-map telegram/edit-message).
PanelKey is the app's stable identifier for the logical panel (e.g. a spot-day card). The neutral layer does not know which platform message that maps to — the mapping from PanelKey to a concrete message reference is a projection concern the caller owns (architecture.md §4.2: "PanelKey→caller- supplied message ref mapping stays outside"). The renderer only records the intent to update in place; a platform with no edit endpoint (WhatsApp — capability-map whatsapp/edit-message absent) degrades it to an append.
type MediaRef ¶
MediaRef is a single image to accompany the message (capability vocabulary: media; capability-map telegram/send-photo, whatsapp/send-image).
Exactly one of ImageURL or MediaID identifies the asset. MediaID is a platform-uploaded asset handle (Meta recommends it over URL — capability-map whatsapp/send-image); ImageURL is a publicly hosted asset. Caption is optional and, where the platform supports it, rendered alongside the image.
type MessagePlan ¶
type MessagePlan struct {
Text Rich // required
Prompt *ActionPrompt // optional: choose one of N, ≤64-byte tokens
URLAction *URLAction // optional: one labelled web link
LivePanel *LivePanel // optional: update-this-message-if-possible
Proactive *ProactiveSpec // nil = reply; non-nil = proactive (purpose→template)
Media *MediaRef // optional: one image
}
MessagePlan is the neutral statement of a single conversational turn's intent, composed once by the application and rendered per platform (architecture.md §4.1).
Text is required; everything else is optional. The optional parts combine under these rules, which the app must respect and Validate enforces:
Prompt and URLAction MAY coexist. They render together where a platform supports both in one message (Telegram: inline buttons + a URL button in the same keyboard). Where they cannot (WhatsApp in-window: reply buttons and a cta_url button are mutually exclusive per message — capability-map whatsapp/cta-url-button), the renderer emits them as two messages, prompt first. The neutral layer permits the combination; the renderer resolves it.
LivePanel is an update-in-place marker; it composes with Text/Prompt (you can edit a panel that has buttons). It has no meaning together with Proactive on WhatsApp (an out-of-window proactive send is a fresh template, never an edit) — Validate rejects that combination as incoherent.
Proactive nil means "reply"; non-nil means "bot-initiated". A reply can never be delivered out of a closed window by construction (a reply implies the user just wrote, so the window is open) — the renderer relies on this.
Media composes with any of the above; a platform without media support degrades it (the renderer records the loss).
func (MessagePlan) IsProactive ¶
func (p MessagePlan) IsProactive() bool
IsProactive reports whether the plan is a bot-initiated send (as opposed to a reply). Equivalent to plan.Proactive != nil.
func (MessagePlan) Validate ¶
func (p MessagePlan) Validate() error
Validate checks the plan's neutral-layer invariants and delegates to the parts.
It enforces the required-Text rule, the LivePanel-with-Proactive incoherence, and each part's own Validate (prompt token lengths, URL action completeness). It does NOT check platform-specific limits (button counts, markup) — those belong to the renderer, which knows the target's Descriptor.
type Platform ¶
type Platform string
Platform names a messaging platform for renderer selection. It is a string so adapters can register without a shared enum the neutral layer would have to grow per platform.
type ProactiveSpec ¶
type ProactiveSpec struct {
// Purpose is the template-catalog purpose key (e.g. "intent_notice").
Purpose string
// Locale selects the template localisation (e.g. "en" or "en_US"). Empty
// lets the catalog fall back to its default.
Locale string
// Params maps template placeholder names to values. On WhatsApp these become
// the template's body parameters, in the order the TemplateDef declares.
Params map[string]string
}
ProactiveSpec marks a plan as a proactive (bot-initiated) send rather than a reply, and names the purpose that selects an approved template when the platform's send window is closed (capability vocabulary: gated-send).
A nil *ProactiveSpec on a MessagePlan means "this is a reply" — deliverable as a free-form message with no template involved. A non-nil ProactiveSpec on WhatsApp is gated by the 24-hour customer-service window (capability-map whatsapp/customer-service-window): in-window it renders free-form, out-of- window it must map Purpose→an approved template (capability-map whatsapp/template-messages). On Telegram a proactive send is an ordinary message — Telegram has no window and no templates (capability-map telegram/proactive-messaging, telegram/message-templates absent) — so Purpose/Params are ignored there.
type RenderTarget ¶
RenderTarget carries the dynamic facts a renderer needs beyond its static Descriptor: which platform and chat type, whether the send window is open right now, and the locale to render in.
WindowOpen is only meaningful when the platform's Descriptor.WindowGated is true (WhatsApp). For Telegram it is effectively always open and the renderer ignores it (capability-map telegram/proactive-messaging).
type Renderer ¶
type Renderer interface {
// Render produces the concrete messages for plan on target, or a typed error.
Render(plan MessagePlan, target RenderTarget) ([]botmsg.MessageFromBot, error)
// Descriptor returns the renderer's static capability fact-sheet.
Descriptor() Descriptor
}
Renderer turns a neutral MessagePlan into one or more concrete botmsg.MessageFromBot values for a specific platform, degrading capabilities the target lacks.
A single plan may yield several messages: a WhatsApp plan carrying both a prompt and a URL action becomes a prompt message followed by a cta_url message (capability-map whatsapp/cta-url-button — the two cannot share one message). The messages are returned in send order.
Errors are typed so callers can react by policy: ErrNoTemplateForPurpose and ErrTemplateMismatch signal an out-of-window proactive send that cannot be delivered as-is (scenario-catalogue SYS-TPL-030 — the caller applies the scenario's declared degradation), ErrInvalidPlan a malformed plan, and ErrUnsupportedTarget a platform the renderer does not implement.
type Rich ¶
type Rich struct {
Lines []Line
}
Rich is a neutral rich-text model: an ordered list of lines, each carrying a kind (paragraph, list-item, quote) and a sequence of styled spans.
It is deliberately a pragmatic MVP, not a full document model. It captures exactly the structure the ToGethered scenarios use — emphasis, inline code, anchored links, bulleted lists and quoted lines — and nothing more. Crucially it holds NO HTML or markdown strings: the renderer, not the app, decides how a BoldSpan becomes "<b>…</b>" (Telegram) or "*…*" (WhatsApp) or is dropped (plain text). See capability-map telegram/text-formatting and whatsapp/text-formatting.
func RichText ¶
RichText builds a single-paragraph Rich from a plain string. A convenience for the common case of an unformatted message.
func (Rich) PlainString ¶
PlainString flattens Rich to unstyled text, one line per Line, dropping all styling. List items gain a "• " prefix and quotes a "> " prefix so the shape survives. Renderers use their own markup path; this is a neutral fallback and a convenience for tests and logging.
type Span ¶
Span is a styled inline run of text. For SpanLink, URL is the target and Text is the anchor; for every other kind URL is empty.
type SpanKind ¶
type SpanKind int
SpanKind classifies an inline run of text within a Line.
const ( // SpanText is unstyled text. SpanText SpanKind = iota // SpanBold is bold text. SpanBold // SpanItalic is italic text. SpanItalic // SpanCode is inline monospace/code text. SpanCode // SpanLink is an anchored hyperlink: Text is the anchor, URL the target. // On platforms without anchor-text links (WhatsApp — see capability-map // whatsapp/text-formatting noAnchorTextLinks) the renderer degrades this to // "anchor: url" so the destination stays reachable. SpanLink )
type TextMarkup ¶
type TextMarkup int
TextMarkup identifies how a platform expresses rich-text styling, so a Rich renderer knows which dialect to emit.
const ( // MarkupPlain: no styling; spans render as their text. (No platform in the // pilot set is plain-only, but a renderer may downgrade to it.) MarkupPlain TextMarkup = iota // MarkupHTML: an HTML subset with a parse-mode opt-in and anchor-text links. // Telegram (capability-map telegram/text-formatting: htmlTags include // b/i/code/a, hyperlinks=true). MarkupHTML // MarkupMarkers: always-on inline markers, no parse-mode, no anchor-text // links. WhatsApp (capability-map whatsapp/text-formatting: markers map, // noParseModeParameter, noAnchorTextLinks). MarkupMarkers )
type URLAction ¶
URLAction is one labelled tappable web link carrying a token in its URL (capability vocabulary: url-action).
On Telegram it is a URL inline button; on WhatsApp in-window it is an interactive cta_url button (capability-map whatsapp/cta-url-button), and out of window it can only ride a template's approved URL button (capability-map whatsapp/template-buttons urlButtonBaseFixedAtApproval).