diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index bc8dfc344..86ac84332 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -1416,3 +1416,76 @@ func (h *Holder) AsksNow() []AskView { }) return out } + +// AsksQuery narrows what the tool `operator-channel.asks` answers (issue 595): Key one ask, State open (open and held) or +// ended, Since the asks opened at or after that time, Bound the most bytes of the answer's asks. The zero query keeps the +// whole answer, as before. +type AsksQuery struct { + Key, State string + Since time.Time + Bound int +} + +// AsksAnswer is the answer of a narrowed query: the asks kept, how many the filters matched, and, when the bound cut the +// answer short, that it did and how. +type AsksAnswer struct { + Asks []AskView `json:"asks"` + Matched int `json:"matched"` + Cut string `json:"cut,omitempty"` +} + +// AsksQueryOf reads the arguments of the tool; an empty set is the zero query. +func AsksQueryOf(a map[string]any) (AsksQuery, error) { + q := AsksQuery{Key: strArg(a, "key"), State: strArg(a, "state")} + if q.State != "" && q.State != "open" && q.State != "ended" { + return q, errorf("state is open or ended, not %q", q.State) + } + if since := strArg(a, "since"); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err != nil { + return q, errorf("since is a time like 2026-11-04T22:00:00Z: %v", err) + } + q.Since = t + } + if v, ok := a["bound"].(float64); ok { + if v < 1 { + return q, errorf("bound is the most bytes of the answer, at least 1") + } + q.Bound = int(v) + } + return q, nil +} + +// Narrow keeps of the views (open first, newest first) the asks the query names, and cuts the answer at its byte bound: the +// asks that fit, in order, and a said cut when one more did not. +func (q AsksQuery) Narrow(all []AskView) AsksAnswer { + var out AsksAnswer + out.Asks = []AskView{} + var kept []AskView + for _, v := range all { + open := v.State == StateOpen || v.State == StateHeld + switch { + case q.Key != "" && v.Key != q.Key, + q.State == "open" && !open, + q.State == "ended" && open, + !q.Since.IsZero() && v.Opened < q.Since.UTC().Format(time.RFC3339): + continue + } + kept = append(kept, v) + } + out.Matched = len(kept) + used := 0 + for i, v := range kept { + if q.Bound > 0 { + raw, _ := json.Marshal(v) + if used+len(raw)+1 > q.Bound { + out.Cut = fmt.Sprintf("the answer is cut at its bound of %d bytes: %d of %d asks are shown, the %d after them are not "+ + "(narrow with key, state or since, or raise bound)", q.Bound, i, len(kept), len(kept)-i) + break + } + used += len(raw) + 1 + } + out.Asks = append(out.Asks, v) + } + return out +} diff --git a/modules/messenger/cmd/messenger/held_test.go b/modules/messenger/cmd/messenger/held_test.go index 1efae214f..2d6c74c1e 100644 --- a/modules/messenger/cmd/messenger/held_test.go +++ b/modules/messenger/cmd/messenger/held_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "reflect" "strings" "testing" "time" @@ -140,3 +141,55 @@ func TestAHeldAskNamesEveryKindAndWhyWhenNoChannelIsReady(t *testing.T) { t.Fatalf("%q", why) } } + +// Issue 595: operator-channel.asks answers one ask, the open or the ended, those opened from a time, and no more bytes than a +// bound, said when it cut the answer short; with no argument it keeps the whole answer. +func TestTheAsksVerbNarrowsItsAnswer(t *testing.T) { + view := func(key, state, opened string) AskView { + return AskView{Key: key, Asker: "mesh-delivery", Headline: "Approve " + key, State: state, Opened: opened} + } + all := []AskView{view("a.3", StateOpen, "2026-11-04T10:00:00Z"), view("a.2", StateHeld, "2026-11-03T10:00:00Z"), + view("a.1", "ended", "2026-11-02T10:00:00Z"), view("a.0", "ended", "2026-11-01T10:00:00Z")} + one, _ := json.Marshal(all[0]) + for _, tc := range []struct { + name string + args map[string]any + want []string + matched int + cut bool + err bool + }{ + {"no argument is the zero query", map[string]any{}, nil, 0, false, false}, + {"key", map[string]any{"key": "a.1"}, []string{"a.1"}, 1, false, false}, + {"state open takes open and held", map[string]any{"state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"state ended", map[string]any{"state": "ended"}, []string{"a.1", "a.0"}, 2, false, false}, + {"since", map[string]any{"since": "2026-11-02T12:00:00Z"}, []string{"a.3", "a.2"}, 2, false, false}, + {"bound cuts and says so", map[string]any{"bound": float64(2*len(one) + 10), "state": "open"}, []string{"a.3", "a.2"}, 2, false, false}, + {"a bound under the asks cuts", map[string]any{"bound": float64(len(one) + 5)}, []string{"a.3"}, 4, true, false}, + {"a state that is neither", map[string]any{"state": "done"}, nil, 0, false, true}, + {"a time that is not one", map[string]any{"since": "yesterday"}, nil, 0, false, true}, + } { + q, err := AsksQueryOf(tc.args) + if (err != nil) != tc.err { + t.Errorf("%s: err = %v", tc.name, err) + continue + } + if tc.err { + continue + } + if tc.want == nil { + if q != (AsksQuery{}) { + t.Errorf("%s: %+v is not the zero query, which keeps today's answer", tc.name, q) + } + continue + } + got := q.Narrow(all) + var keys []string + for _, v := range got.Asks { + keys = append(keys, v.Key) + } + if !reflect.DeepEqual(keys, tc.want) || got.Matched != tc.matched || (got.Cut != "") != tc.cut { + t.Errorf("%s: asks %v matched %d cut %q, want %v, %d, cut %v", tc.name, keys, got.Matched, got.Cut, tc.want, tc.matched, tc.cut) + } + } +} diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index f237289a1..d1346ef97 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -734,8 +734,24 @@ func tools(h *Holder, l *listening) []stdio.Tool { {Name: "operator-channel.asks", Description: "The asks this router holds (novox/hq ADR 0259): open (or held, with since when and why no channel can carry it yet) first, then the last month's — each " + "one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " + - "ended (the warrant: who chose what, through which channel), and whether its asker was told.", - Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }}, + "ended (the warrant: who chose what, through which channel), and whether its asker was told. With no argument every " + + "ask, which is large; filter it: key for one ask, state open or ended, since (a time, RFC 3339) for the asks opened " + + "from then, and bound, the most bytes of the answer, which says when it cut it short.", + Input: map[string]any{ + "key": str("one ask, by its key (asker.id); none for every ask"), + "state": str("open (open and held) or ended; none for both"), + "since": str("only the asks opened at or after this time, RFC 3339"), + "bound": map[string]any{"type": "integer", "description": "the most bytes of the asks in the answer; said when it cuts the answer short"}}, + Run: func(a map[string]any) (any, error) { + q, err := AsksQueryOf(a) + if err != nil { + return nil, err + } + if q == (AsksQuery{}) { + return h.AsksNow(), nil + } + return q.Narrow(h.AsksNow()), nil + }}, {Name: "messenger_identities", Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + "service shows, when it was linked, and from when it can approve (an hour after linking).",