// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentaclewire import ( "encoding/json" "errors" "strings" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/speech" ) // SpeechConfiguration is a complete voice configuration, never arbitrary reader // state. Enablement, interruption policy, input and unrelated preferences remain // under the core's control. Nil voice parameters retain server/inherited defaults. type SpeechConfiguration struct { Server string `json:"server"` Module string `json:"module"` Device string `json:"device,omitempty"` Baud int `json:"baud,omitempty"` DefaultVoice config.Voice `json:"default_voice"` UppercaseVoice config.Voice `json:"uppercase_voice"` HyperlinkVoice config.Voice `json:"hyperlink_voice"` SystemVoice config.Voice `json:"system_voice"` RaisePitchForCapitals bool `json:"raise_pitch_for_capitals"` BeepForCapitals bool `json:"beep_for_capitals"` SayCapForCapitals bool `json:"say_cap_for_capitals"` Punctuation int `json:"punctuation"` CustomEcho bool `json:"custom_echo"` CustomEchoServer bool `json:"custom_echo_server"` EchoServer string `json:"echo_server,omitempty"` EchoVoice config.Voice `json:"echo_voice"` EchoForKey bool `json:"echo_for_key"` EchoForCharacter bool `json:"echo_for_character"` EchoForWord bool `json:"echo_for_word"` EchoForSentence bool `json:"echo_for_sentence"` } func SpeechFromSettings(settings config.Settings) SpeechConfiguration { return SpeechConfiguration{ Server: settings.SpeechServerFactory, Module: settings.SpeechOutputModule, Device: settings.HardwareSpeechDevice, Baud: settings.HardwareSpeechBaudRate, DefaultVoice: settings.DefaultVoice, UppercaseVoice: settings.UppercaseVoice, HyperlinkVoice: settings.HyperlinkVoice, SystemVoice: settings.SystemVoice, RaisePitchForCapitals: settings.RaisePitchForCapitals, BeepForCapitals: settings.BeepForCapitals, SayCapForCapitals: settings.SayCapForCapitals, Punctuation: settings.VerbalizePunctuationStyle, CustomEcho: settings.UseCustomEchoVoice, CustomEchoServer: settings.UseCustomEchoSpeechServer, EchoServer: settings.EchoSpeechServer, EchoVoice: settings.EchoVoice, EchoForKey: settings.UseCustomEchoForKey, EchoForCharacter: settings.UseCustomEchoForCharacter, EchoForWord: settings.UseCustomEchoForWord, EchoForSentence: settings.UseCustomEchoForSentence, } } func (value SpeechConfiguration) Apply(settings config.Settings) config.Settings { settings.SpeechServerFactory, settings.SpeechOutputModule = value.Server, value.Module settings.HardwareSpeechDevice, settings.HardwareSpeechBaudRate = value.Device, value.Baud settings.DefaultVoice, settings.UppercaseVoice = value.DefaultVoice, value.UppercaseVoice settings.HyperlinkVoice, settings.SystemVoice = value.HyperlinkVoice, value.SystemVoice settings.RaisePitchForCapitals, settings.BeepForCapitals, settings.SayCapForCapitals = value.RaisePitchForCapitals, value.BeepForCapitals, value.SayCapForCapitals settings.VerbalizePunctuationStyle = value.Punctuation settings.UseCustomEchoVoice, settings.UseCustomEchoSpeechServer = value.CustomEcho, value.CustomEchoServer settings.EchoSpeechServer, settings.EchoVoice = value.EchoServer, value.EchoVoice settings.UseCustomEchoForKey, settings.UseCustomEchoForCharacter = value.EchoForKey, value.EchoForCharacter settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = value.EchoForWord, value.EchoForSentence return settings } type PreferencePage struct { ID string `json:"id"` Title string `json:"title"` Command string `json:"command"` Load bool `json:"load,omitempty"` Elements []interaction.Element `json:"elements"` Values map[string]interaction.Value `json:"values"` } type QuickSetting struct { ID string `json:"id"` Label string `json:"label"` Command string `json:"command"` Options []interaction.Item `json:"options"` Selected string `json:"selected"` } // Extensions are copied at registration/update. IDs are local to one session. type Extensions struct { Pages []PreferencePage `json:"pages,omitempty"` QuickSettings []QuickSetting `json:"quick_settings,omitempty"` } // ReaderRequest is serviced on the settings worker. Snapshot returns a copied // state to Next; Module optionally requests that module's catalog. Mutations // are atomic within this request and never persist the active speech override. type ReaderRequest struct { Snapshot bool `json:"snapshot,omitempty"` Module string `json:"module,omitempty"` Speech *SpeechConfiguration `json:"speech_configuration,omitempty"` RestoreSpeech bool `json:"restore_speech,omitempty"` Extensions *Extensions `json:"extensions,omitempty"` } type ReaderState struct { Speech SpeechConfiguration `json:"speech_configuration"` DefaultSpeech SpeechConfiguration `json:"default_speech_configuration"` Modules []string `json:"modules,omitempty"` Voices []speech.Voice `json:"voices,omitempty"` CatalogTruncated bool `json:"catalog_truncated,omitempty"` Extensions Extensions `json:"extensions"` } // ExtensionEvent handlers propose effects only. The host commits page values // and quick selections after validating all proposals and applying core settings. type ExtensionEvent struct { Event string `json:"event"` ID string `json:"id"` Values map[string]interaction.Value `json:"values,omitempty"` Selected string `json:"selected,omitempty"` Reader ReaderState `json:"reader"` } func valid_label(value string) bool { return strings.TrimSpace(value) != "" && len(value) <= 256 && validFormText(value) } func ValidExtensionID(value string) bool { if len(value) == 0 || len(value) > 64 { return false } for _, character := range value { if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') { return false } } return true } func ValidateExtensions(value Extensions) error { if len(value.Pages) > 4 || len(value.QuickSettings) > 8 { return ErrLimit } ids := map[string]bool{} for _, page := range value.Pages { if !ValidExtensionID(page.ID) || ids[page.ID] || !valid_label(page.Title) || !ValidExtensionID(page.Command) { return ErrProtocol } ids[page.ID] = true if err := interaction.ValidatePreferences(interaction.Form{Title: page.Title, Elements: page.Elements}); err != nil { return ErrProtocol } for _, element := range page.Elements { if !ValidExtensionID(element.ID) || !valid_label(element.Label) || element.Kind == interaction.Button { return ErrProtocol } for _, item := range element.Items { if !ValidExtensionID(item.ID) || !valid_label(item.Label) { return ErrProtocol } } } if err := ValidatePageValues(page, page.Values); err != nil { return err } } clear(ids) for _, setting := range value.QuickSettings { if !ValidExtensionID(setting.ID) || ids[setting.ID] || !valid_label(setting.Label) || !ValidExtensionID(setting.Command) || len(setting.Options) == 0 || len(setting.Options) > 64 { return ErrProtocol } ids[setting.ID] = true options := map[string]bool{} selected := false for _, option := range setting.Options { if !ValidExtensionID(option.ID) || options[option.ID] || !valid_label(option.Label) { return ErrProtocol } options[option.ID] = true selected = selected || option.ID == setting.Selected && !option.Disabled } if !selected { return ErrProtocol } } data, err := json.Marshal(value) if err != nil || len(data) > 32*1024 { return ErrLimit } return nil } func ValidatePageValues(page PreferencePage, values map[string]interaction.Value) error { if len(values) != len(page.Elements) { return errors.New("page values do not match its controls") } for _, element := range page.Elements { if element.Secret { return ErrProtocol } value, found := values[element.ID] if !found { return ErrProtocol } switch element.Kind { case interaction.Entry: limit := element.MaxLength if limit == 0 { limit = 8192 } if !validFormText(value.Text) || len([]rune(value.Text)) > limit || value.Selected != "" || value.Checked || element.Required && strings.TrimSpace(value.Text) == "" { return ErrProtocol } case interaction.CheckBox: if value.Text != "" || value.Selected != "" { return ErrProtocol } case interaction.List: valid := false for _, item := range element.Items { valid = valid || item.ID == value.Selected && !item.Disabled } if !valid || value.Text != "" || value.Checked { return ErrProtocol } default: return ErrProtocol } } return nil } func ValidateReaderRequest(value ReaderRequest) error { if value.Snapshot && (value.Speech != nil || value.RestoreSpeech || value.Extensions != nil) || value.Speech != nil && value.RestoreSpeech || value.Module != "" && !value.Snapshot || len(value.Module) > 256 || !validFormText(value.Module) { return ErrProtocol } if !value.Snapshot && value.Speech == nil && !value.RestoreSpeech && value.Extensions == nil { return ErrProtocol } if value.Speech != nil { if err := config.Validate(value.Speech.Apply(config.DefaultSettings())); err != nil { return err } } if value.Extensions != nil { return ValidateExtensions(*value.Extensions) } return nil }