129 lines
4.0 KiB
Go
129 lines
4.0 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tentaclewire
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Leave room for lease metadata inside the existing 8192-byte call bound.
|
|
const MaxRemoteSpeechBytes = 6144
|
|
const RemoteSpeechQueueSize = 64
|
|
const RemoteSpeechQueueBytes = 256 * 1024
|
|
|
|
// RemoteSpeechPart carries resolved character/language state for each fragment.
|
|
// End is an utterance boundary, not text. Whitespace is significant.
|
|
type RemoteSpeechPart struct {
|
|
Text string `json:"text,omitempty"`
|
|
Characters bool `json:"characters,omitempty"`
|
|
Language string `json:"language,omitempty"`
|
|
End bool `json:"end,omitempty"`
|
|
}
|
|
|
|
type RemoteSpeech struct {
|
|
Kind string `json:"kind"` // speak, cancel, pause
|
|
Priority int `json:"priority,omitempty"` // normal=0, next=1, now=2
|
|
Parts []RemoteSpeechPart `json:"parts,omitempty"`
|
|
Paused *bool `json:"paused,omitempty"`
|
|
}
|
|
|
|
type RemotePresentation struct {
|
|
RemoteOperation
|
|
Speech RemoteSpeech `json:"speech"`
|
|
Utterance uint64 `json:"utterance,omitempty"` // First fragment's operation sequence.
|
|
Final bool `json:"final,omitempty"`
|
|
}
|
|
|
|
// A zero utterance is a complete, single-call effect. A nonzero utterance
|
|
// stages speak fragments until Final; the reader admits them as one delivery.
|
|
func ValidRemotePresentation(request RemotePresentation) bool {
|
|
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 &&
|
|
ValidRemoteSpeech(request.Speech) &&
|
|
(request.Utterance == 0 && !request.Final ||
|
|
request.Utterance > 0 && request.Utterance <= request.Sequence && request.Speech.Kind == "speak")
|
|
}
|
|
|
|
func (request *RemotePresentation) UnmarshalJSON(data []byte) error {
|
|
type plain RemotePresentation
|
|
var value plain
|
|
if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil || !ValidRemotePresentation(RemotePresentation(value)) {
|
|
return ErrProtocol
|
|
}
|
|
*request = RemotePresentation(value)
|
|
return nil
|
|
}
|
|
|
|
func reject_speech_nulls(data []byte) error {
|
|
var fields map[string]json.RawMessage
|
|
if json.Unmarshal(data, &fields) != nil || fields == nil {
|
|
return ErrProtocol
|
|
}
|
|
for _, value := range fields {
|
|
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (part *RemoteSpeechPart) UnmarshalJSON(data []byte) error {
|
|
type plain RemoteSpeechPart
|
|
var value plain
|
|
if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil {
|
|
return ErrProtocol
|
|
}
|
|
*part = RemoteSpeechPart(value)
|
|
return nil
|
|
}
|
|
|
|
func (speech *RemoteSpeech) UnmarshalJSON(data []byte) error {
|
|
type plain RemoteSpeech
|
|
var value plain
|
|
if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil || !ValidRemoteSpeech(RemoteSpeech(value)) {
|
|
return ErrProtocol
|
|
}
|
|
*speech = RemoteSpeech(value)
|
|
return nil
|
|
}
|
|
|
|
// ValidRemoteSpeech also limits encoded bytes: escaped text must fit in IPC.
|
|
func ValidRemoteSpeech(speech RemoteSpeech) bool {
|
|
switch speech.Kind {
|
|
case "speak":
|
|
if speech.Priority < 0 || speech.Priority > 2 || speech.Paused != nil || len(speech.Parts) == 0 || len(speech.Parts) > 128 {
|
|
return false
|
|
}
|
|
for _, part := range speech.Parts {
|
|
if part.End {
|
|
if part.Text != "" || part.Characters || part.Language != "" {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if part.Text == "" || !utf8.ValidString(part.Text) || len(part.Language) > 64 {
|
|
return false
|
|
}
|
|
for _, char := range part.Language {
|
|
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
case "cancel":
|
|
if speech.Priority != 0 || len(speech.Parts) != 0 || speech.Paused != nil {
|
|
return false
|
|
}
|
|
case "pause":
|
|
if speech.Priority != 0 || len(speech.Parts) != 0 || speech.Paused == nil {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
data, err := json.Marshal(speech)
|
|
return err == nil && len(data) <= MaxRemoteSpeechBytes
|
|
}
|