// Package spec loads and validates a declared Telegram presence. // // The normative schema is presence/telegram.schema.yaml. The validation here // mirrors it deliberately rather than interpreting it at runtime: the rules are // few, and a spec that fails should say why in the tool's own words. The // mirroring is a real drift risk, so schema_test.go asserts that the schema file // and this package still agree on every rule. package spec import ( "crypto/sha256" "encoding/hex" "fmt" "os" "regexp" "strings" "gopkg.in/yaml.v3" ) // PostMessages is the only administrator right this system will ever hold. // // InterfaceEvolutionIntent.md 7 forbids the Daimon from using a wider right, and // a right that is never exercised is still a right that was granted. Enforcing // it where the grant is declared is cheaper than auditing that it stayed unused. const PostMessages = "post_messages" type File struct { Presence Presence `yaml:"presence"` } type Presence struct { SchemaVersion string `yaml:"schema_version"` Campaign string `yaml:"campaign"` Interface string `yaml:"interface"` Bot Bot `yaml:"bot"` Channels map[string]Channel `yaml:"channels"` LinkedDiscussionGroup bool `yaml:"linked_discussion_group"` } type Bot struct { Name string `yaml:"name"` UsernamePreference []string `yaml:"username_preference"` About string `yaml:"about"` Description string `yaml:"description"` Avatar string `yaml:"avatar"` } type Channel struct { Title string `yaml:"title"` Description string `yaml:"description"` Visibility string `yaml:"visibility"` UsernamePreference []string `yaml:"username_preference"` AdminRights []string `yaml:"admin_rights"` } const ( Private = "private" Public = "public" // The two channels every presence declares. Test is created first and is // where renderings are checked; nothing reaches Public until a person has // looked at one. Test = "test" Live = "public" ) var ( campaignRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) botUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,30}([Bb]ot|_bot)$`) chanUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`) ) // Load reads a spec and validates it. The digest is over the file bytes, so any // edit changes it -- that is what makes drift detectable in the resolved state. func Load(path string) (*Presence, string, error) { raw, err := os.ReadFile(path) if err != nil { return nil, "", fmt.Errorf("read spec: %w", err) } var f File dec := yaml.NewDecoder(strings.NewReader(string(raw))) dec.KnownFields(true) // additionalProperties: false if err := dec.Decode(&f); err != nil { return nil, "", fmt.Errorf("parse spec: %w", err) } if err := f.Presence.Validate(); err != nil { return nil, "", err } sum := sha256.Sum256(raw) return &f.Presence, "sha256:" + hex.EncodeToString(sum[:]), nil } func (p *Presence) Validate() error { var errs []string add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) } if p.SchemaVersion != "0.1" { add("presence.schema_version must be %q, got %q", "0.1", p.SchemaVersion) } if !campaignRe.MatchString(p.Campaign) { add("presence.campaign %q must be a lowercase slug", p.Campaign) } if p.Interface == "" { add("presence.interface is required") } if p.Bot.Name == "" { add("bot.name is required") } else if n := len([]rune(p.Bot.Name)); n > 64 { add("bot.name is %d characters, limit is 64", n) } if len(p.Bot.UsernamePreference) == 0 { add("bot.username_preference needs at least one candidate; BotFather usernames are globally unique and a first choice is often taken") } for _, u := range p.Bot.UsernamePreference { if !botUsernameRe.MatchString(u) { add("bot username %q must be 4-31 characters and end in \"bot\" or \"_bot\"", u) } } if n := len([]rune(p.Bot.About)); n > 120 { add("bot.about is %d characters, limit is 120", n) } if n := len([]rune(p.Bot.Description)); n > 512 { add("bot.description is %d characters, limit is 512", n) } for _, name := range []string{Test, Live} { if _, ok := p.Channels[name]; !ok { add("channels.%s is required", name) } } for name, c := range p.Channels { if name != Test && name != Live { add("channels.%s is not a known channel; expected %q and %q", name, Test, Live) continue } errs = append(errs, c.validate(name)...) } if len(errs) > 0 { return fmt.Errorf("spec is invalid:\n - %s", strings.Join(errs, "\n - ")) } return nil } func (c Channel) validate(name string) []string { var errs []string add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) } if c.Title == "" { add("channels.%s.title is required", name) } else if n := len([]rune(c.Title)); n > 128 { add("channels.%s.title is %d characters, limit is 128", name, n) } if n := len([]rune(c.Description)); n > 255 { add("channels.%s.description is %d characters, limit is 255", name, n) } switch c.Visibility { case Private: if len(c.UsernamePreference) > 0 { add("channels.%s is private and must not declare a username; a dropped field is how a spec stops describing what exists", name) } case Public: if len(c.UsernamePreference) == 0 { add("channels.%s is public and must declare at least one username candidate", name) } default: add("channels.%s.visibility must be %q or %q, got %q", name, Private, Public, c.Visibility) } for _, u := range c.UsernamePreference { if !chanUsernameRe.MatchString(u) { add("channels.%s username %q must be 5-32 characters, starting with a letter", name, u) } } // The clamp. A spec asking for a wider right fails here rather than being // quietly trimmed: someone asked for it, and they should find out. for _, r := range c.AdminRights { if r != PostMessages { add("channels.%s.admin_rights may only contain %q; %q is refused", name, PostMessages, r) } } if len(c.AdminRights) > 1 { add("channels.%s.admin_rights has %d entries; only %q is permitted", name, len(c.AdminRights), PostMessages) } return errs } // Rights returns the rights to grant. Always exactly post_messages: the spec may // state it for readability, but it is not the spec's decision. func (c Channel) Rights() []string { return []string{PostMessages} }