]> git.giacomolli.uk - onechat/blobdiff - notify.go
Submit root events through the local socket
[onechat] / notify.go
index a892590fb306292a538bb021752ca9517f745a36..1036312c176d2983a7f1b5e8d15284f2be8b9089 100644 (file)
--- a/notify.go
+++ b/notify.go
@@ -1,10 +1,14 @@
 package main
 
 import (
+       "context"
        "crypto/sha256"
        "encoding/json"
        "errors"
        "fmt"
+       "io"
+       "net"
+       "path/filepath"
        "strings"
        "time"
        "unicode/utf8"
@@ -27,6 +31,21 @@ const (
        // retry keys. A full store rejects new keys. Pending events and
        // receipts never expire.
        notifyReceipts = 1024
+
+       // notifyTimeout limits a local submission, not the agent's processing
+       // time.
+       notifyTimeout = 10 * time.Second
+
+       notifyUsage = `Usage: onechat notify --source SOURCE --id ID --text TEXT
+
+Send untrusted external data to the root agent at its next turn boundary.
+Success means durably accepted, not processed. Retry with the same source,
+ID, and text after an uncertain result. Changed text for an existing key fails.
+Text is limited to 32768 bytes. Source and ID are 1 to 128 ASCII letters,
+digits, dots, underscores, or hyphens. The local test interface keeps up to
+1024 receipts without expiry and rejects new keys when full.
+Child targets, receipt expiry, and processing status are not supported.
+`
 )
 
 // NotifyInput is caller-supplied data. Its source label proves no identity or
@@ -46,6 +65,141 @@ type NotifyReceipt struct {
        Number int    `json:"number"`
 }
 
+// notify submits a local event and prints only its acceptance receipt. The
+// deadline and caller cancellation interrupt socket I/O. Losing the answer is
+// an uncertain result, so the caller must retry the same key and text.
+func notify(ctx context.Context, args []string, out io.Writer) (err error) {
+       if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
+               _, err = io.WriteString(out, notifyUsage)
+               if err != nil {
+                       return fmt.Errorf("write notification help: %w", err)
+               }
+
+               return nil
+       }
+
+       input, err := notifyParse(args)
+       if err != nil {
+               return fmt.Errorf("parse notification: %w", err)
+       }
+
+       dir, err := dataDir()
+       if err != nil {
+               return fmt.Errorf("find notification data directory: %w", err)
+       }
+
+       ctx, cancel := context.WithTimeout(ctx, notifyTimeout)
+       defer cancel()
+
+       var dialer net.Dialer
+
+       conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName))
+       if err != nil {
+               return fmt.Errorf("connect notification to running daemon: %w", err)
+       }
+
+       defer func() {
+               closeErr := conn.Close()
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("close notification socket: %w", closeErr))
+               }
+       }()
+
+       interrupted := make(chan error, 1)
+       stop := context.AfterFunc(ctx, func() {
+               err := conn.SetDeadline(time.Now())
+               if err != nil {
+                       err = fmt.Errorf("interrupt notification I/O: %w", err)
+               }
+
+               interrupted <- err
+       })
+
+       defer func() {
+               if !stop() {
+                       err = errors.Join(err, <-interrupted)
+               }
+       }()
+
+       err = json.NewEncoder(conn).Encode(SocketRequest{
+               Type:   "notify",
+               Notify: &input,
+       })
+       if err != nil {
+               return fmt.Errorf("send notification: %w", err)
+       }
+
+       decoder := json.NewDecoder(io.LimitReader(conn, socketRequestBytes))
+       for {
+               var event Event
+
+               err = decoder.Decode(&event)
+               if ctx.Err() != nil {
+                       return ctx.Err()
+               }
+
+               if err != nil {
+                       return fmt.Errorf("read notification receipt: %w", err)
+               }
+
+               switch event.Kind {
+               case "notify":
+                       if event.ID <= 0 || (event.Status != "accepted" && event.Status != "duplicate") {
+                               return errors.New("invalid notification receipt")
+                       }
+
+                       err = json.NewEncoder(out).Encode(event)
+                       if err != nil {
+                               return fmt.Errorf("write notification receipt: %w", err)
+                       }
+
+                       return nil
+               case "error":
+                       return fmt.Errorf("notification rejected: %s", event.Text)
+               }
+       }
+}
+
+// notifyParse accepts GNU long flags only and rejects repeated or unknown
+// flags.
+func notifyParse(args []string) (NotifyInput, error) {
+       var input NotifyInput
+
+       fields := map[string]*string{
+               "--source": &input.Source,
+               "--id":     &input.ID,
+               "--text":   &input.Text,
+       }
+
+       seen := make(map[string]bool)
+       for i := 0; i < len(args); i++ {
+               name, value, equals := strings.Cut(args[i], "=")
+               field := fields[name]
+               if field == nil || seen[name] {
+                       return input, errors.New("unknown or repeated notification flag")
+               }
+
+               seen[name] = true
+               if !equals {
+                       i++
+                       if i == len(args) {
+                               return input, errors.New("notification flag needs a value")
+                       }
+
+                       value = args[i]
+               }
+
+               *field = value
+       }
+
+       err := notifyValidate(input)
+       if err != nil {
+               return input, fmt.Errorf("validate notification: %w", err)
+       }
+
+       return input, nil
+}
+
 // notifyValidate checks both CLI and direct socket inputs without echoing data.
 func notifyValidate(input NotifyInput) error {
        if !notifyIdentifier(input.Source) || !notifyIdentifier(input.ID) {