botplan

package
v0.77.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: Apache-2.0 Imports: 3 Imported by: 0

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

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

type ActionPrompt struct {
	Choices    []Choice
	LayoutRows int
}

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).

const (
	// ChatPrivate is a 1:1 chat.
	ChatPrivate ChatType = iota
	// ChatGroup is a group chat.
	ChatGroup
)

type Choice

type Choice struct {
	Label string
	Token string
}

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 Line

type Line struct {
	Kind  LineKind
	Spans []Span
}

Line is one line of rich text: a kind plus the spans that compose it.

func Item

func Item(spans ...Span) Line

Item returns a list-item line from the given spans.

func Para

func Para(spans ...Span) Line

Para returns a paragraph line from the given spans.

func Quote

func Quote(spans ...Span) Line

Quote returns a quote line from the given spans.

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

type MediaRef struct {
	ImageURL string
	MediaID  string
	Caption  string
}

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.

const (
	// PlatformTelegram is the Telegram platform.
	PlatformTelegram Platform = "telegram"
	// PlatformWhatsApp is the WhatsApp platform.
	PlatformWhatsApp Platform = "whatsapp"
)

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

type RenderTarget struct {
	Platform   Platform
	ChatType   ChatType
	WindowOpen bool
	Locale     string
}

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

func RichText(s string) Rich

RichText builds a single-paragraph Rich from a plain string. A convenience for the common case of an unformatted message.

func (Rich) IsEmpty

func (r Rich) IsEmpty() bool

IsEmpty reports whether the Rich carries no lines or only empty lines.

func (Rich) PlainString

func (r Rich) PlainString() string

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

type Span struct {
	Kind SpanKind
	Text string
	URL  string // set only when Kind == SpanLink
}

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.

func Bold

func Bold(s string) Span

Bold returns a bold span.

func Code

func Code(s string) Span

Code returns an inline-code span.

func Italic

func Italic(s string) Span

Italic returns an italic span.

func Link(anchor, url string) Span

Link returns an anchored-link span (anchor text + target URL).

func Text

func Text(s string) Span

Text returns an unstyled span.

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

type URLAction struct {
	Label string
	URL   string
}

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).

func (URLAction) Validate

func (u URLAction) Validate() error

Validate checks a URLAction has both a label and a URL.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL