Skip to content

Commit 9c3a6ed

Browse files
trakhimenokclaude
andcommitted
feat(botplan): neutral message-plan model and capability descriptors
New botplan package: the platform-neutral layer-5 rendering seam (design items F4 + F5) for the ToGethered cross-platform conversation model. Types: - Rich: lines of typed spans (text/bold/italic/code/link) plus list-item and quote line kinds — carries no HTML/markdown; each renderer emits its own dialect. - ActionPrompt{Choices, LayoutRows} with Choice{Label, Token}; tokens are botstoken-encoded and validated <=64 bytes (Telegram callback_data ceiling). - URLAction, LivePanel, ProactiveSpec (nil = reply), MediaRef. - MessagePlan combining them with documented mutual-exclusion rules (Prompt+URLAction may coexist; LivePanel excludes Proactive) and Validate. - Descriptor (F5): per-platform capability fact-sheet; every field cites a record in can-i-use/capability-map.json so descriptors can be CI-checked. - Renderer interface Render(plan, target) -> []botmsg.MessageFromBot with RenderTarget and typed errors incl. ErrNoTemplateForPurpose, ErrTemplateMismatch, ErrInvalidPlan, ErrUnsupportedTarget. Strictly additive; table/golden tests cover token-length validation, plan invariants, Rich flattening and descriptor defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LipBTY2YeDYJEWvmATgdDJ
1 parent bb7c952 commit 9c3a6ed

12 files changed

Lines changed: 938 additions & 0 deletions

File tree

botplan/descriptor.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package botplan
2+
3+
// TextMarkup identifies how a platform expresses rich-text styling, so a Rich
4+
// renderer knows which dialect to emit.
5+
type TextMarkup int
6+
7+
const (
8+
// MarkupPlain: no styling; spans render as their text. (No platform in the
9+
// pilot set is plain-only, but a renderer may downgrade to it.)
10+
MarkupPlain TextMarkup = iota
11+
// MarkupHTML: an HTML subset with a parse-mode opt-in and anchor-text links.
12+
// Telegram (capability-map telegram/text-formatting: htmlTags include
13+
// b/i/code/a, hyperlinks=true).
14+
MarkupHTML
15+
// MarkupMarkers: always-on inline markers, no parse-mode, no anchor-text
16+
// links. WhatsApp (capability-map whatsapp/text-formatting: markers map,
17+
// noParseModeParameter, noAnchorTextLinks).
18+
MarkupMarkers
19+
)
20+
21+
// Descriptor is the static per-platform capability fact-sheet a renderer
22+
// consults. It is the F5 half of the seam: every field mirrors a record in
23+
// can-i-use/capability-map.json (cited in the field's doc comment) so the
24+
// descriptor a renderer ships can be CI-checked against the platform facts.
25+
//
26+
// It carries only the facts the two pilot renderers genuinely consult when
27+
// shaping a MessagePlan — not the whole capability map. Dynamic facts (is the
28+
// window open right now, is this template approved) are NOT here; they live on
29+
// RenderTarget and in the template catalog, because they vary per send.
30+
type Descriptor struct {
31+
// MaxPromptButtons is the largest number of choices that render as inline/
32+
// reply buttons before the renderer must degrade to a list or pages.
33+
// Telegram: no fixed small cap (inline grid) — set high. WhatsApp:
34+
// capability-map whatsapp/reply-buttons constraints.maxButtons = 3.
35+
MaxPromptButtons int
36+
37+
// MaxListRows is the largest number of choices that render as a single
38+
// selectable list before paging is required. Telegram has no list message
39+
// (buttons only) — 0 means "not applicable". WhatsApp: capability-map
40+
// whatsapp/list-messages constraints.maxRowsAcrossAllSections = 10.
41+
MaxListRows int
42+
43+
// MaxButtonLabelChars is the button/row label ceiling; longer labels are
44+
// truncated. Telegram: no documented hard limit — 0 means "unbounded".
45+
// WhatsApp: capability-map whatsapp/reply-buttons buttonLabelMaxChars = 20.
46+
MaxButtonLabelChars int
47+
48+
// SupportsEdit reports whether the platform can update a message in place
49+
// (LivePanel). Telegram: capability-map telegram/edit-message native = true.
50+
// WhatsApp: capability-map whatsapp/edit-message absent = false → append.
51+
SupportsEdit bool
52+
53+
// SupportsDelete reports whether the platform can retract a message.
54+
// Telegram: capability-map telegram/delete-message native = true. WhatsApp:
55+
// capability-map whatsapp/delete-message absent = false.
56+
SupportsDelete bool
57+
58+
// SupportsCallbackAck reports whether a tap can be acknowledged (toast /
59+
// spinner-stop). Telegram: capability-map telegram/callback-query
60+
// semanticsRequiresAck = true. WhatsApp: capability-map whatsapp/callback-ack
61+
// absent = false — there is nothing to ack.
62+
SupportsCallbackAck bool
63+
64+
// WindowGated reports whether proactive sends are gated by a service window
65+
// that may force a template. Telegram: false (capability-map
66+
// telegram/proactive-messaging native, no window). WhatsApp: true
67+
// (capability-map whatsapp/customer-service-window partial, 24h).
68+
WindowGated bool
69+
70+
// SupportsInlineURLButton reports whether a fully dynamic URL button can ride
71+
// a normal (non-template) message. Telegram: capability-map
72+
// telegram/inline-keyboard (URL buttons) = true. WhatsApp: capability-map
73+
// whatsapp/cta-url-button native = true, but only in-window — the renderer
74+
// combines this flag with RenderTarget.WindowOpen.
75+
SupportsInlineURLButton bool
76+
77+
// SupportsButtonGrid reports whether buttons arrange in a multi-column grid
78+
// (so ActionPrompt.LayoutRows is meaningful). Telegram: capability-map
79+
// telegram/inline-keyboard constraints.layout = "grid" = true. WhatsApp:
80+
// capability-map whatsapp/reply-buttons grid = false.
81+
SupportsButtonGrid bool
82+
83+
// SupportsMedia reports whether the platform can send an image. Telegram:
84+
// capability-map telegram/send-photo native = true. WhatsApp: capability-map
85+
// whatsapp/send-image native = true (though the client may not yet implement
86+
// it — a platform fact, not a client fact).
87+
SupportsMedia bool
88+
89+
// TextMarkup is the styling dialect the Rich renderer must emit. See
90+
// TextMarkup. Telegram: MarkupHTML. WhatsApp: MarkupMarkers.
91+
TextMarkup TextMarkup
92+
93+
// SupportsAnchorTextLinks reports whether a link span can render as anchor
94+
// text over a hidden URL. Telegram: capability-map telegram/text-formatting
95+
// hyperlinks = true. WhatsApp: capability-map whatsapp/text-formatting
96+
// noAnchorTextLinks → false, so links render as "anchor: url".
97+
SupportsAnchorTextLinks bool
98+
}

botplan/descriptor_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package botplan
2+
3+
import "testing"
4+
5+
// TestDescriptorZeroValue documents that the zero Descriptor is the most
6+
// conservative platform: no buttons, no edit/delete/ack, plain text, no media.
7+
// A renderer that forgets to set a field therefore degrades rather than
8+
// over-promises.
9+
func TestDescriptorZeroValue(t *testing.T) {
10+
var d Descriptor
11+
if d.MaxPromptButtons != 0 || d.MaxListRows != 0 {
12+
t.Error("zero descriptor should advertise no buttons")
13+
}
14+
if d.SupportsEdit || d.SupportsDelete || d.SupportsCallbackAck ||
15+
d.WindowGated || d.SupportsInlineURLButton || d.SupportsButtonGrid ||
16+
d.SupportsMedia || d.SupportsAnchorTextLinks {
17+
t.Error("zero descriptor should advertise no capabilities")
18+
}
19+
if d.TextMarkup != MarkupPlain {
20+
t.Error("zero descriptor markup should be plain")
21+
}
22+
}
23+
24+
func TestTextMarkupDistinct(t *testing.T) {
25+
if MarkupPlain == MarkupHTML || MarkupHTML == MarkupMarkers || MarkupPlain == MarkupMarkers {
26+
t.Error("markup constants must be distinct")
27+
}
28+
}
29+
30+
func TestPlatformConstants(t *testing.T) {
31+
if PlatformTelegram != "telegram" || PlatformWhatsApp != "whatsapp" {
32+
t.Errorf("platform constants changed: %q %q", PlatformTelegram, PlatformWhatsApp)
33+
}
34+
}

botplan/doc.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Package botplan defines the neutral message-plan model and the capability
2+
// descriptor that per-platform renderers consult (the "capability-driven
3+
// rendering" seam, layer 5 of the ToGethered conversation architecture).
4+
//
5+
// An application composes a MessagePlan once — a platform-neutral statement of
6+
// intent: some rich text, optionally a prompt (choose one of N), a URL action,
7+
// a live-panel marker, a proactive-send spec, and/or a single image. It never
8+
// mentions Telegram or WhatsApp. A per-platform Renderer then turns that plan
9+
// into one or more botmsg.MessageFromBot values, degrading capabilities the
10+
// target platform lacks (a WhatsApp button grid becomes reply-buttons, then a
11+
// list, then a paged list; a live-panel edit becomes an append; an out-of-window
12+
// proactive send becomes an approved template).
13+
//
14+
// Two ideas keep platform conditionals out of the application:
15+
//
16+
// - The neutral types carry no HTML or markdown strings. Text is modelled as
17+
// lines of typed spans (Rich) so each renderer can emit HTML (Telegram),
18+
// WhatsApp markers, or plain text without the app choosing.
19+
//
20+
// - Descriptor records the facts a renderer genuinely consults — button
21+
// ceilings, edit/delete support, markup dialect, window gating. Every field
22+
// mirrors a record in can-i-use/capability-map.json (cited per field) so the
23+
// descriptors can be CI-checked against the platform facts.
24+
//
25+
// Renderers live in the adapter repos (bots-fw-telegram, bots-fw-whatsapp); this
26+
// package owns only the neutral vocabulary and the interface they implement.
27+
package botplan

botplan/errors.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package botplan
2+
3+
import "fmt"
4+
5+
// ErrInvalidPlan is the base error for a MessagePlan (or one of its parts) that
6+
// violates a neutral-layer invariant. Callers can match it with errors.Is.
7+
var ErrInvalidPlan = fmt.Errorf("botplan: invalid message plan")
8+
9+
// ErrTokenTooLong is returned when a Choice.Token exceeds MaxChoiceTokenBytes.
10+
// It wraps ErrInvalidPlan, so errors.Is(err, ErrInvalidPlan) also holds — a
11+
// too-long token is one kind of invalid plan.
12+
var ErrTokenTooLong = fmt.Errorf("%w: choice token exceeds 64 bytes", ErrInvalidPlan)
13+
14+
// ErrNoTemplateForPurpose is returned by a renderer when a proactive plan must
15+
// be delivered out of the platform's send window but no approved template exists
16+
// for its ProactiveSpec.Purpose.
17+
//
18+
// This is not a bug — it is a scenario-specific policy point (scenario-catalogue
19+
// SYS-TPL-030: "degrade the scenario, not the product"). The renderer surfaces
20+
// it typed so the caller can apply that scenario's declared fallback (hold until
21+
// the window reopens, drop, defer to a digest, …) rather than the renderer
22+
// guessing one.
23+
var ErrNoTemplateForPurpose = fmt.Errorf("botplan: no approved template for proactive purpose")
24+
25+
// ErrTemplateMismatch is returned by a renderer when an approved template exists
26+
// for the purpose but its shape does not match the plan — for example the
27+
// prompt's choices do not line up with the template's approved quick-reply
28+
// labels, or the params do not cover the template's body placeholders. Quick-
29+
// reply buttons on a WhatsApp template are fixed at approval time, not composed
30+
// per send (capability-map whatsapp/template-buttons buttonsFixedAtApprovalNotPerSend),
31+
// so a plan that assumes different buttons cannot be honoured and must fail
32+
// loudly rather than silently drop the choices.
33+
var ErrTemplateMismatch = fmt.Errorf("botplan: plan does not match approved template shape")
34+
35+
// ErrUnsupportedTarget is returned by a renderer asked to render for a platform
36+
// it does not implement.
37+
var ErrUnsupportedTarget = fmt.Errorf("botplan: unsupported render target platform")

botplan/plan.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package botplan
2+
3+
import "fmt"
4+
5+
// MessagePlan is the neutral statement of a single conversational turn's intent,
6+
// composed once by the application and rendered per platform (architecture.md
7+
// §4.1).
8+
//
9+
// Text is required; everything else is optional. The optional parts combine
10+
// under these rules, which the app must respect and Validate enforces:
11+
//
12+
// - Prompt and URLAction MAY coexist. They render together where a platform
13+
// supports both in one message (Telegram: inline buttons + a URL button in
14+
// the same keyboard). Where they cannot (WhatsApp in-window: reply buttons
15+
// and a cta_url button are mutually exclusive per message — capability-map
16+
// whatsapp/cta-url-button), the renderer emits them as two messages, prompt
17+
// first. The neutral layer permits the combination; the renderer resolves
18+
// it.
19+
//
20+
// - LivePanel is an update-in-place marker; it composes with Text/Prompt (you
21+
// can edit a panel that has buttons). It has no meaning together with
22+
// Proactive on WhatsApp (an out-of-window proactive send is a fresh template,
23+
// never an edit) — Validate rejects that combination as incoherent.
24+
//
25+
// - Proactive nil means "reply"; non-nil means "bot-initiated". A reply can
26+
// never be delivered out of a closed window by construction (a reply implies
27+
// the user just wrote, so the window is open) — the renderer relies on this.
28+
//
29+
// - Media composes with any of the above; a platform without media support
30+
// degrades it (the renderer records the loss).
31+
type MessagePlan struct {
32+
Text Rich // required
33+
Prompt *ActionPrompt // optional: choose one of N, ≤64-byte tokens
34+
URLAction *URLAction // optional: one labelled web link
35+
LivePanel *LivePanel // optional: update-this-message-if-possible
36+
Proactive *ProactiveSpec // nil = reply; non-nil = proactive (purpose→template)
37+
Media *MediaRef // optional: one image
38+
}
39+
40+
// IsProactive reports whether the plan is a bot-initiated send (as opposed to a
41+
// reply). Equivalent to plan.Proactive != nil.
42+
func (p MessagePlan) IsProactive() bool { return p.Proactive != nil }
43+
44+
// Validate checks the plan's neutral-layer invariants and delegates to the parts.
45+
//
46+
// It enforces the required-Text rule, the LivePanel-with-Proactive
47+
// incoherence, and each part's own Validate (prompt token lengths, URL action
48+
// completeness). It does NOT check platform-specific limits (button counts,
49+
// markup) — those belong to the renderer, which knows the target's Descriptor.
50+
func (p MessagePlan) Validate() error {
51+
if p.Text.IsEmpty() && p.Media == nil {
52+
return fmt.Errorf("%w: plan has neither text nor media", ErrInvalidPlan)
53+
}
54+
if p.LivePanel != nil && p.Proactive != nil {
55+
return fmt.Errorf("%w: LivePanel (edit-in-place) cannot combine with Proactive (fresh send)", ErrInvalidPlan)
56+
}
57+
if p.Prompt != nil {
58+
if err := p.Prompt.Validate(); err != nil {
59+
return err
60+
}
61+
}
62+
if p.URLAction != nil {
63+
if err := p.URLAction.Validate(); err != nil {
64+
return err
65+
}
66+
}
67+
return nil
68+
}

botplan/plan_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package botplan
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestMessagePlanValidate(t *testing.T) {
9+
validPrompt := &ActionPrompt{Choices: []Choice{{Label: "Yes", Token: "y"}}}
10+
11+
tests := []struct {
12+
name string
13+
plan MessagePlan
14+
wantErr error
15+
}{
16+
{
17+
name: "empty plan (no text, no media)",
18+
plan: MessagePlan{},
19+
wantErr: ErrInvalidPlan,
20+
},
21+
{
22+
name: "text only is valid",
23+
plan: MessagePlan{Text: RichText("hi")},
24+
wantErr: nil,
25+
},
26+
{
27+
name: "media only (no text) is valid",
28+
plan: MessagePlan{Media: &MediaRef{ImageURL: "https://x.io/a.jpg"}},
29+
wantErr: nil,
30+
},
31+
{
32+
name: "text + valid prompt",
33+
plan: MessagePlan{Text: RichText("hi"), Prompt: validPrompt},
34+
wantErr: nil,
35+
},
36+
{
37+
name: "text + invalid prompt propagates",
38+
plan: MessagePlan{Text: RichText("hi"), Prompt: &ActionPrompt{
39+
Choices: []Choice{{Label: "", Token: "t"}},
40+
}},
41+
wantErr: ErrInvalidPlan,
42+
},
43+
{
44+
name: "prompt + urlaction coexist (renderer resolves)",
45+
plan: MessagePlan{
46+
Text: RichText("hi"),
47+
Prompt: validPrompt,
48+
URLAction: &URLAction{Label: "View", URL: "https://x.io"},
49+
},
50+
wantErr: nil,
51+
},
52+
{
53+
name: "livepanel + proactive is incoherent",
54+
plan: MessagePlan{
55+
Text: RichText("hi"),
56+
LivePanel: &LivePanel{PanelKey: "card1"},
57+
Proactive: &ProactiveSpec{Purpose: "notice"},
58+
},
59+
wantErr: ErrInvalidPlan,
60+
},
61+
{
62+
name: "livepanel + prompt (edit a panel with buttons) is valid",
63+
plan: MessagePlan{
64+
Text: RichText("hi"),
65+
LivePanel: &LivePanel{PanelKey: "card1"},
66+
Prompt: validPrompt,
67+
},
68+
wantErr: nil,
69+
},
70+
{
71+
name: "invalid urlaction propagates",
72+
plan: MessagePlan{
73+
Text: RichText("hi"),
74+
URLAction: &URLAction{Label: "View"},
75+
},
76+
wantErr: ErrInvalidPlan,
77+
},
78+
}
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
err := tt.plan.Validate()
82+
if tt.wantErr == nil {
83+
if err != nil {
84+
t.Fatalf("Validate() = %v, want nil", err)
85+
}
86+
return
87+
}
88+
if !errors.Is(err, tt.wantErr) {
89+
t.Fatalf("Validate() = %v, want errors.Is %v", err, tt.wantErr)
90+
}
91+
})
92+
}
93+
}
94+
95+
func TestMessagePlanIsProactive(t *testing.T) {
96+
if (MessagePlan{}).IsProactive() {
97+
t.Error("nil Proactive should be a reply")
98+
}
99+
if !(MessagePlan{Proactive: &ProactiveSpec{}}).IsProactive() {
100+
t.Error("non-nil Proactive should be proactive")
101+
}
102+
}

0 commit comments

Comments
 (0)