package main
import (
+ "context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
+ "io"
+ "net"
+ "path/filepath"
"strings"
"time"
"unicode/utf8"
// 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
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) {