A proposal reads whole on the phone, and Telegram shows every message formatted (hq issue 383)
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone checking: 0 of 2 member(s) ready
mesh/merge-gate pass: builds claude-code, messenger, telegram → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did with…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone checking: 0 of 2 member(s) ready
mesh/merge-gate pass: builds claude-code, messenger, telegram → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did with…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The operator could not read a settings proposal on the phone: its values were masked ("+ shares:
media=‹path›"), a how-to paragraph followed the fingerprint, and nothing was formatted.
messenger: an ask may carry its explanation whole (asks.Ask.Whole); the router shows it in
place of the explanation only on a kind the controller issued as verified-sender and that was
given the ask's answers, and holds it to the secret rule alone (CheckSecret): a path or an
address is what the operator approves, a secret's shape leaves the mesh nowhere and refuses the
ask as a whole. Everywhere else, and for every condition, the explanation under the content rule
stands. An ask's own message offers Details when the ask says what Details shows
(asks.Ask.Details): the fingerprint and where to read the proposal whole. The expiry line reads
"Expires <when>: <what then>".
telegram: every message is sent in HTML, formatted and never reworded: the title bold, a key
line or a fingerprint in monospace, the chosen line bold, and every '<', '>' and '&' escaped
first. What a tap is compared by stays the plain words, which is what Telegram gives back.
claude-code: an instruction proposal's ask has the same shape — what is asked, the text, who
proposed it and when; the fingerprint and the how-to are the Details answer's; where the sender
is proven, a line with a path or an address is shown, one shaped like a secret is not.
Takes mesh-sdk go at the commit that adds Whole and Details to an ask.
This commit is contained in:
@@ -148,8 +148,10 @@ said in the render's answer. What was registered before is still rendered and ma
|
||||
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
||||
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
||||
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
||||
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
||||
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
||||
proves who answered (Telegram) carries either. The phone shows what is asked, the text and who proposed it when
|
||||
(hq issue 383): the whole text when it is short, else its first lines; a line that might not leave the mesh is kept
|
||||
back where the sender is not proven, and only one shaped like a secret where it is. The Details answer shows the
|
||||
fingerprint and where to read all of it, at the controller's terminal with
|
||||
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
||||
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
||||
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
||||
|
||||
@@ -165,10 +165,14 @@ func (p Proposal) Act(option string) asks.Act {
|
||||
}
|
||||
|
||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||
// router's warrant to it.
|
||||
// router's warrant to it. Its shape is a settings proposal's (novox/hq issue 383): what is asked, the text, who
|
||||
// proposed it and when; the fingerprint and where to read all of it are the Details answer's. The explanation
|
||||
// keeps back every line that might not leave the mesh; the whole (asks.Ask.Whole), shown only where the sender
|
||||
// is proven, keeps back only a line shaped like a secret or a long unbroken word.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
shown, whole := excerpt(p.Text)
|
||||
shown, whole := excerpt(p.Text, shownLine, "a path, an address or a long word")
|
||||
wholeShown, _ := excerpt(p.Text, wholeLine, "a long word or a secret's shape")
|
||||
part := ""
|
||||
if p.Action != ActionRemove && !whole {
|
||||
part = " Part shown"
|
||||
@@ -177,37 +181,46 @@ func (p Proposal) Ask() asks.Ask {
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " Claude instructions?" + part
|
||||
}
|
||||
var b strings.Builder
|
||||
switch p.Action {
|
||||
case ActionAdd:
|
||||
fmt.Fprintf(&b, "Add instructions '%s' to %s?\n\n", p.Name, p.where())
|
||||
case ActionReplace:
|
||||
fmt.Fprintf(&b, "Replace the instructions '%s' in %s?\n\n", p.Name, p.where())
|
||||
case ActionRemove:
|
||||
fmt.Fprintf(&b, "Remove the instructions '%s' from %s?\n\n", p.Name, p.where())
|
||||
}
|
||||
fmt.Fprintf(&b, "An agent proposed this through the claude-code module on %s, at %s UTC.", p.From,
|
||||
p.At.UTC().Format("15:04 on 2 Jan"))
|
||||
if p.Action == ActionRemove {
|
||||
fmt.Fprintf(&b, " The approved text it removes has the fingerprint %s.", p.Fingerprint())
|
||||
} else {
|
||||
fmt.Fprintf(&b, " The text has %d lines, %d bytes, fingerprint %s. Once approved, every new session there "+
|
||||
"reads it as the mesh's own word until it is removed the same way.\n\n", p.Lines(), len(p.Text), p.Fingerprint())
|
||||
if whole {
|
||||
b.WriteString("The whole text:\n\n" + shown)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "NOT SHOWN IN FULL here. Its first lines:\n\n%s\n\nRead all of it before you answer, at the controller's terminal: "+
|
||||
"mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'. The fingerprint there must be %s.",
|
||||
shown, p.ID, p.Fingerprint())
|
||||
compose := func(text string) string {
|
||||
var b strings.Builder
|
||||
switch p.Action {
|
||||
case ActionAdd:
|
||||
fmt.Fprintf(&b, "Add instructions '%s' to %s?\n\n", p.Name, p.where())
|
||||
case ActionReplace:
|
||||
fmt.Fprintf(&b, "Replace the instructions '%s' in %s?\n\n", p.Name, p.where())
|
||||
case ActionRemove:
|
||||
fmt.Fprintf(&b, "Remove the instructions '%s' from %s?\n\n", p.Name, p.where())
|
||||
}
|
||||
if p.Action != ActionRemove {
|
||||
if !whole {
|
||||
b.WriteString("NOT SHOWN IN FULL here; its first lines:\n\n")
|
||||
}
|
||||
b.WriteString(text + "\n\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Proposed by an agent through the claude-code module on %s, at %s UTC.", p.From,
|
||||
p.At.UTC().Format("15:04 on 2 Jan"))
|
||||
return b.String()
|
||||
}
|
||||
var d strings.Builder
|
||||
if p.Action == ActionRemove {
|
||||
fmt.Fprintf(&d, "The approved text it removes has the fingerprint %s.\n", p.Fingerprint())
|
||||
} else {
|
||||
fmt.Fprintf(&d, "The text has %d lines, %d bytes, fingerprint %s.\n", p.Lines(), len(p.Text), p.Fingerprint())
|
||||
d.WriteString("Once approved, every new session there reads it as the mesh's own word until it is removed the same way.\n")
|
||||
}
|
||||
fmt.Fprintf(&d, "Read all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals "+
|
||||
"'{\"id\": \"%s\"}'. The fingerprint there must be %s.", p.ID, p.Fingerprint())
|
||||
does := "the text becomes part of those sessions' instructions"
|
||||
if p.Action == ActionRemove {
|
||||
does = "the instructions are removed from those sessions"
|
||||
}
|
||||
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator,
|
||||
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
|
||||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name}
|
||||
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name,
|
||||
Details: d.String()}
|
||||
if p.Action != ActionRemove && wholeShown != shown {
|
||||
a.Whole = compose(wholeShown)
|
||||
}
|
||||
if len(p.Nodes) == 0 {
|
||||
a.About = "instructions." + p.Scope + "." + p.Name
|
||||
}
|
||||
@@ -221,10 +234,10 @@ func (p Proposal) Ask() asks.Ask {
|
||||
return a
|
||||
}
|
||||
|
||||
// excerpt is what of a text the operator's phone is shown, and whether it is the whole text. A line that might
|
||||
// carry what may not leave the mesh (a path, an address, a long unbroken word) is not shown, and a marker says
|
||||
// so; the router refuses an ask whose words carry one, and the whole text is read at the terminal.
|
||||
func excerpt(text string) (string, bool) {
|
||||
// excerpt is what of a text the operator's phone is shown, and whether it is the whole text: every line that
|
||||
// may be shown (shownLine for the explanation, wholeLine for the whole), up to the bounds; a line kept back is
|
||||
// counted in a marker that says why (hidden), and the whole text is read at the terminal.
|
||||
func excerpt(text string, may func(string) bool, hidden string) (string, bool) {
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
whole := len(text) <= WholeTextBytes && len(lines) <= WholeTextLines
|
||||
limitBytes, limitLines := FirstBytes, FirstLines
|
||||
@@ -232,11 +245,11 @@ func excerpt(text string) (string, bool) {
|
||||
limitBytes, limitLines = WholeTextBytes, WholeTextLines
|
||||
}
|
||||
var out []string
|
||||
size, hidden := 0, 0
|
||||
size, kept := 0, 0
|
||||
flush := func() {
|
||||
if hidden > 0 {
|
||||
out = append(out, fmt.Sprintf("[%d line(s) not shown here: a path, an address or a long word]", hidden))
|
||||
hidden = 0
|
||||
if kept > 0 {
|
||||
out = append(out, fmt.Sprintf("[%d line(s) not shown here: %s]", kept, hidden))
|
||||
kept = 0
|
||||
}
|
||||
}
|
||||
for i, line := range lines {
|
||||
@@ -244,9 +257,9 @@ func excerpt(text string) (string, bool) {
|
||||
whole = false
|
||||
break
|
||||
}
|
||||
if !shownLine(line) {
|
||||
if !may(line) {
|
||||
whole = false
|
||||
hidden++
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
@@ -265,6 +278,19 @@ var (
|
||||
assigned = regexp.MustCompile(`(?i)(password|passwd|passphrase|secret|token|api[_-]?key|credential|private[_-]?key)\s*[=:]`)
|
||||
)
|
||||
|
||||
// wholeLine says whether a line may be shown where the sender is proven (asks.Ask.Whole): a path, an address or
|
||||
// a host-like name may, as what the operator approves; a long unbroken word, a hexadecimal run or a value given
|
||||
// to a secret's name may not — the router refuses the whole ask for a secret's shape, so nothing that might be
|
||||
// one is risked.
|
||||
func wholeLine(line string) bool {
|
||||
for _, re := range []*regexp.Regexp{unbroken, hexRun, assigned} {
|
||||
if re.MatchString(line) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// shownLine says whether a line may be shown on the phone: no path, address, host-like name, long unbroken word
|
||||
// or value given to a secret's name. Deliberately narrower than the router's own rule, so an ask is rarely
|
||||
// refused for its words; a line kept back is read at the terminal.
|
||||
|
||||
@@ -153,8 +153,18 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||
t.Errorf("headline: %q", a.Headline)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
||||
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
||||
if !strings.Contains(a.Explanation, "?\n\n"+strings.TrimRight(handover, "\n")+"\n\nProposed by an agent through the claude-code module on g14, at ") {
|
||||
t.Errorf("a short text is shown whole, then who proposed it: %q", a.Explanation)
|
||||
}
|
||||
// The shape of a settings proposal (issue 383): no fingerprint and no how-to in the message; both in Details.
|
||||
if strings.Contains(a.Explanation, "ingerprint") || strings.Contains(a.Explanation, "mesh-cli") || strings.Contains(a.Explanation, "Once approved") {
|
||||
t.Errorf("the message carries what Details shows: %q", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Details, "fingerprint "+fingerprintOf(textDigest(handover))) || !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals '{\"id\": \""+id+"\"}'") {
|
||||
t.Errorf("Details: %q", a.Details)
|
||||
}
|
||||
if a.Whole != "" {
|
||||
t.Errorf("a text every line of which is shown has whole words of its own: %q", a.Whole)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
t.Fatalf("options: %+v", a.Options)
|
||||
@@ -420,9 +430,30 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
||||
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
|
||||
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
|
||||
if !strings.Contains(a.Explanation, "NOT SHOWN IN FULL here") || strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") {
|
||||
t.Errorf("the message does not say it is cut, or says where to read all of it (Details does): %q", a.Explanation)
|
||||
}
|
||||
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!strings.Contains(a.Details, "fingerprint there must be "+out["fingerprint"].(string)) {
|
||||
t.Errorf("Details do not say where to read all of it: %q", a.Details)
|
||||
}
|
||||
// Where the sender is proven, the line with the path is shown; the cut stands.
|
||||
if !strings.Contains(a.Whole, "See /etc/claude-code for the managed files.") || !strings.Contains(a.Whole, "NOT SHOWN IN FULL here") ||
|
||||
strings.Contains(a.Whole, "not shown here") {
|
||||
t.Errorf("the whole words: %q", a.Whole)
|
||||
}
|
||||
// The whole words keep back what might be a secret's shape (Propose refuses such a text before it is asked;
|
||||
// this holds the ask's own composition to it), and nothing else.
|
||||
for line, may := range map[string]bool{
|
||||
"See /etc/claude-code for the managed files.": true,
|
||||
"Ask on nas.lan, or mail ops@example.com.": true,
|
||||
"token: 0123456789abcdef0123456789abcdef": false,
|
||||
"api_key=whatever": false,
|
||||
"abcdefghijklmnopqrstuvwxyz0123456789": false,
|
||||
} {
|
||||
if wholeLine(line) != may {
|
||||
t.Errorf("wholeLine(%q) = %v", line, !may)
|
||||
}
|
||||
}
|
||||
if len(a.Explanation) > 3000 {
|
||||
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
||||
|
||||
@@ -2,6 +2,6 @@ module claude-code
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0 h1:qi0slgoZVexIr/gxfLhN8kJxy2LVvGTHiWuuGBPkfDo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -108,8 +108,15 @@ operator (urgent at its bound, or its words now say "Needs you:"). An urgent con
|
||||
|
||||
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
|
||||
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
|
||||
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
|
||||
warning kept quiet is also said once after a day.
|
||||
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen — on a
|
||||
condition's message its summary, key and start; on an ask's own message what the asker gave for it (a settings
|
||||
proposal's fingerprint and where to read it whole, hq issue 383). A warning kept quiet is also said once after a day.
|
||||
|
||||
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
|
||||
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
|
||||
are. The whole is shown only on a kind the controller issued as `verified-sender` and that was given the ask's
|
||||
answers; everywhere else, and for every condition, the explanation under the content rule stands. A secret's
|
||||
shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole (`CheckSecret`).
|
||||
|
||||
| When | What |
|
||||
|---|---|
|
||||
|
||||
@@ -78,15 +78,16 @@ func needsNothing(r *Record) bool {
|
||||
}
|
||||
|
||||
// choices are the answers a record's next message offers on one kind: the open ask's options offered
|
||||
// there, and Details for a condition, with a ticket of its own. The tickets of the record's earlier messages
|
||||
// stand (issue 371): each message keeps a Details of its own, used once, until it is TokenFor old or
|
||||
// MostDetails newer ones on that kind push it out.
|
||||
// there, and Details — for a condition, and for an ask's own message while the ask says what Details shows
|
||||
// (issue 383) — with a ticket of its own. The tickets of the record's earlier messages stand (issue 371):
|
||||
// each message keeps a Details of its own, used once, until it is TokenFor old or MostDetails newer ones on
|
||||
// that kind push it out.
|
||||
func (h *Holder) choices(r *Record, kind string) []Choice {
|
||||
var out []Choice
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
out = h.offers(rec, kind)
|
||||
}
|
||||
if r.Origin != "condition" || len(out) >= MostActions {
|
||||
if (r.Origin != "condition" && h.detailsOf(r) == "") || len(out) >= MostActions {
|
||||
return out
|
||||
}
|
||||
now := h.Now()
|
||||
@@ -194,9 +195,20 @@ func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
|
||||
h.mu.Unlock()
|
||||
headline, _ := wordsOf(r)
|
||||
// Each line held to the content rule on its own: a line that carries what may not leave is said
|
||||
// withheld, and the rest is still shown.
|
||||
// withheld, and the rest is still shown. A condition's details are its summary, key and start; an ask's
|
||||
// what its asker gave for Details (a fingerprint, where to read it whole), after a condition's own when the
|
||||
// ask is shown on one (issue 383).
|
||||
var said []string
|
||||
if r.Origin == "condition" {
|
||||
said = []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")}
|
||||
} else {
|
||||
said = []string{"Asked: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")}
|
||||
}
|
||||
if details := h.detailsOf(r); details != "" {
|
||||
said = append(said, strings.Split(strings.TrimSpace(details), "\n")...)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
|
||||
for _, l := range said {
|
||||
if refusal, ok := Check(l); !ok {
|
||||
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -163,6 +164,40 @@ func (h *Holder) openAsk(r *Record) *AskRecord {
|
||||
return &rec
|
||||
}
|
||||
|
||||
// wholeOf is an ask's explanation with its values whole (asks.Ask.Whole), where a record's message on one
|
||||
// kind shows it in place of the explanation (novox/hq issue 383): on a kind that proves who answers, as the
|
||||
// controller issued it, and that the router gave the ask's answers (a ticket there, so the kind was linked and
|
||||
// root-free when the ask was taken). Empty where the kind proves nothing, the ask has no whole words, or the
|
||||
// record has no ask — then the explanation, held to the whole content rule, is shown. The ask's state does
|
||||
// not matter: a message edited to say how the ask ended still shows what was approved.
|
||||
func (h *Holder) wholeOf(r *Record, kind string) string {
|
||||
if r == nil || r.Ask == "" || kind == "" || h.Bench == nil {
|
||||
return ""
|
||||
}
|
||||
e := h.askOf(r.Ask)
|
||||
if e == nil || e.rec.Ask.Whole == "" {
|
||||
return ""
|
||||
}
|
||||
info, held := h.Bench.Kind(kind)
|
||||
if !held || !slices.Contains(info.Capabilities, "verified-sender") {
|
||||
return ""
|
||||
}
|
||||
for _, tk := range e.rec.Tickets {
|
||||
if tk.Kind == kind {
|
||||
return e.rec.Ask.Whole
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// detailsOf is what the Details answer shows of a record's ask (asks.Ask.Details), while the ask is open.
|
||||
func (h *Holder) detailsOf(r *Record) string {
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
return rec.Ask.Details
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// carriers are the kinds that can carry an answer of a level now: ready, offering a choice, and for
|
||||
// approve verifying their sender with an account of the operator's linked, where the controller says now
|
||||
// that no agent can become root (rootFree, asked live).
|
||||
@@ -439,6 +474,13 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error {
|
||||
h.refuseAsk(asker, a, "its words carry "+refusal.String()+", which may not leave the mesh: move it into a reference or say it in words")
|
||||
return nil
|
||||
}
|
||||
// The whole words are shown only where the sender is proven (wholeOf), so a path or an address may stand
|
||||
// in them; a secret's shape leaves the mesh nowhere, and an ask carrying one is refused as a whole, never
|
||||
// shown with its explanation alone (novox/hq issue 383).
|
||||
if refusal, ok := CheckSecret(a.Whole); !ok {
|
||||
h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it")
|
||||
return nil
|
||||
}
|
||||
open, replaced := 0, ""
|
||||
h.mu.Lock()
|
||||
for k, e := range h.asks {
|
||||
|
||||
@@ -65,6 +65,43 @@ func Check(text string) (Refusal, bool) {
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
}
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
if reWinPath.MatchString(text) {
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
if refusal, ok := randomRun(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
}) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is
|
||||
// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers,
|
||||
// a path or an address is what the operator approves and may be shown, a secret never.
|
||||
func CheckSecret(text string) (Refusal, bool) {
|
||||
if refusal, ok := secretShape(text); !ok {
|
||||
return refusal, false
|
||||
}
|
||||
return randomRun(text)
|
||||
}
|
||||
|
||||
// secretShape is the secret shapes a pattern names.
|
||||
func secretShape(text string) (Refusal, bool) {
|
||||
switch {
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
@@ -77,25 +114,17 @@ func Check(text string) (Refusal, bool) {
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
case reWinPath.MatchString(text):
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// randomRun is a long unbroken run of characters spread as a random string's are.
|
||||
func randomRun(text string) (Refusal, bool) {
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r)
|
||||
}) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -786,8 +786,18 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
||||
if what == "cleared" || what == "silenced" || m.Title == "A mesh message was withheld" {
|
||||
return m
|
||||
}
|
||||
// On a kind that proves who answers, an ask's explanation is shown with its values whole (issue 383): the
|
||||
// path or the address the operator approves is readable there; a secret's shape still is not.
|
||||
whole := h.wholeOf(r, kind)
|
||||
if whole != "" {
|
||||
if wm, ok := h.wholeWords(r, what, whole); ok {
|
||||
m = wm
|
||||
} else {
|
||||
whole = ""
|
||||
}
|
||||
}
|
||||
if r.Answered != "" {
|
||||
m = h.answeredWords(r, m)
|
||||
m = h.answeredWords(r, m, whole)
|
||||
}
|
||||
if kind == "" {
|
||||
return m
|
||||
@@ -827,13 +837,17 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
|
||||
// itself, so its title becomes how it ended — "You chose Approve at 13:30 on Telegram · <headline>" — read at
|
||||
// a glance on the phone, where the edit rings nothing (issue 372); the rest of what came of it, the
|
||||
// explanation and when it was asked follow. A condition's message keeps its own title, and says it first.
|
||||
func (h *Holder) answeredWords(r *Record, m Message) Message {
|
||||
func (h *Holder) answeredWords(r *Record, m Message, whole string) Message {
|
||||
if r.Origin == "ask" {
|
||||
first, rest, _ := strings.Cut(r.Answered, "\n")
|
||||
headline, explanation := wordsOf(r)
|
||||
passes := Check
|
||||
if whole != "" {
|
||||
explanation, passes = whole, CheckSecret
|
||||
}
|
||||
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Urgent: m.Urgent, Quiet: m.Quiet,
|
||||
Body: strings.TrimSpace(rest + "\n" + explanation + "\nAsked " + when(r.Raised, h.Now(), h.zone()) + ".")}
|
||||
if _, ok := Check(out.Text()); ok {
|
||||
if _, ok := passes(out.Text()); ok {
|
||||
return out
|
||||
}
|
||||
// Its words refused (they are the asker's): the outcome is still said, before what may leave.
|
||||
@@ -842,6 +856,19 @@ func (h *Holder) answeredWords(r *Record, m Message) Message {
|
||||
return m
|
||||
}
|
||||
|
||||
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
|
||||
// secret's shape once composed; else false, and the message under the whole content rule stands.
|
||||
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
|
||||
c := *r
|
||||
c.Explanation = whole
|
||||
m := compose(&c, what, h.Now(), h.zone(), 0)
|
||||
if refusal, ok := CheckSecret(m.Text()); !ok {
|
||||
h.Logf("[messenger] the whole words of %s carry %s; shown as on a channel that proves nothing", r.Key, refusal)
|
||||
return Message{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
|
||||
func (h *Holder) askLines(rec *AskRecord) []string {
|
||||
asker := rec.Asker
|
||||
@@ -855,7 +882,7 @@ func (h *Holder) askLines(rec *AskRecord) []string {
|
||||
}
|
||||
}
|
||||
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
|
||||
lines = append(lines, "If nobody answers by "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".")
|
||||
lines = append(lines, "Expires "+by(rec.Ask.Expires, h.Now(), h.zone())+": "+rec.Ask.OnExpiry+".")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestAnAsksExpiryIsSaidWithItsDay(t *testing.T) {
|
||||
rec.Ask.Expires = now.Add(7 * 24 * time.Hour)
|
||||
rec.Ask.OnExpiry = "nothing is done"
|
||||
lines := strings.Join(h.askLines(rec), "\n")
|
||||
if !strings.Contains(lines, "If nobody answers by Fri 16 Oct 21:33: nothing is done.") {
|
||||
if !strings.Contains(lines, "Expires Fri 16 Oct 21:33: nothing is done.") {
|
||||
t.Fatalf("%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.
|
||||
body := tg.sends[0].Body
|
||||
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
|
||||
"Silence for a week: nothing more is said of it for a week.",
|
||||
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
||||
"Expires " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the message does not say %q:\n%s", want, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// novox/hq issue 383: a settings proposal's values are what the operator approves, so where the sender is proven
|
||||
// they are shown whole; where nothing is proven, the explanation under the whole content rule stands.
|
||||
|
||||
// proposalAsk is a proposal as the controller composes it: the explanation with its values masked, the whole
|
||||
// with them whole, and Details with the fingerprint and where to read it.
|
||||
func proposalAsk(c *clock, id string) asks.Ask {
|
||||
a := heldAsk(c, id)
|
||||
a.Headline = "Set mounts on shanks?"
|
||||
a.Options = a.Options[:2]
|
||||
a.Explanation = "+ shares: media=‹path›\n+ sources: recalbox=‹address›@‹path›\nProposed by an agent at 15:11."
|
||||
a.Whole = "+ shares: media=/storage/media\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox\nProposed by an agent at 15:11."
|
||||
a.Details = "Fingerprint 1234 5678 9abc def0 1234 5678.\nRead it whole: settings proposals " + id + " at the controller's terminal."
|
||||
return a
|
||||
}
|
||||
|
||||
func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *testing.T) {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
submit(t, h, "mesh-controller", proposalAsk(c, "s-1"))
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 {
|
||||
t.Fatalf("telegram got %d messages", len(tg.sends))
|
||||
}
|
||||
phone := tg.sends[0]
|
||||
for _, want := range []string{"+ shares: media=/storage/media", "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "Proposed by an agent at 15:11."} {
|
||||
if !strings.Contains(phone.Body, want) {
|
||||
t.Errorf("the phone, which proves who answers, lacks %q:\n%s", want, phone.Body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(phone.Body, "‹") || strings.Contains(phone.Text(), "Fingerprint") || strings.Contains(phone.Text(), "settings proposals") {
|
||||
t.Errorf("the phone shows a masked value, or the Details' words, in the message:\n%s", phone.Body)
|
||||
}
|
||||
if phone.Title != "Set mounts on shanks?" || ticketFor(phone, "Release") == "" || ticketFor(phone, DetailsLabel) == "" {
|
||||
t.Errorf("the phone's message: %q offering %s", phone.Title, labelsOf(phone))
|
||||
}
|
||||
// The desk proves nothing: the same record composed for it shows the explanation, its values masked.
|
||||
r := h.open["ask.mesh-controller.s-1"]
|
||||
if r == nil {
|
||||
t.Fatal("no record of the ask")
|
||||
}
|
||||
desk := h.sayOn(r, "raised", "desktop")
|
||||
if !strings.Contains(desk.Body, "+ shares: media=‹path›") || strings.Contains(desk.Body, "/storage/media") || strings.Contains(desk.Body, "nas.lan") {
|
||||
t.Errorf("the desk shows a value whole:\n%s", desk.Body)
|
||||
}
|
||||
// Details shows the fingerprint and where to read it whole, and answers nothing.
|
||||
if out, err := h.Chosen("telegram", choiceOf(detailsOf(t, phone), "42", c.now())); err != nil || out != "shown" {
|
||||
t.Fatalf("Details: %q %v", out, err)
|
||||
}
|
||||
if len(tg.sends) != 2 {
|
||||
t.Fatalf("telegram got %d messages after Details", len(tg.sends))
|
||||
}
|
||||
details := tg.sends[1]
|
||||
if details.Title != "Details: Set mounts on shanks?" || !strings.Contains(details.Body, "Fingerprint 1234 5678 9abc def0 1234 5678.") ||
|
||||
!strings.Contains(details.Body, "Read it whole: settings proposals s-1 at the controller's terminal.") || !strings.Contains(details.Body, "Asked: ") {
|
||||
t.Errorf("Details: %q\n%s", details.Title, details.Body)
|
||||
}
|
||||
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
|
||||
t.Errorf("Details answered the ask: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
|
||||
}
|
||||
// Approved, the message is edited to say so, and still shows what was approved, whole.
|
||||
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
|
||||
t.Fatalf("Release: %q %v", out, err)
|
||||
}
|
||||
edited, ok := tg.edits["1"]
|
||||
if !ok {
|
||||
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
|
||||
}
|
||||
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.Contains(edited.Body, "media=/storage/media") ||
|
||||
strings.Contains(edited.Body, "‹") || len(edited.Actions) != 0 {
|
||||
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
// The mutation on the switch: the same ask without whole words is shown on the phone as on the desk, from its
|
||||
// explanation; the whole words alone make the difference, and only where the sender is proven.
|
||||
func TestAnAskWithoutWholeWordsIsShownFromItsExplanationEverywhere(t *testing.T) {
|
||||
h, tg, _, c, store, _ := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
a := proposalAsk(c, "s-2")
|
||||
a.Whole, a.Details = "", ""
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
if len(tg.sends) != 1 || !strings.Contains(tg.sends[0].Body, "+ shares: media=‹path›") || strings.Contains(tg.sends[0].Body, "/storage/media") {
|
||||
t.Fatalf("telegram: %+v", tg.sends)
|
||||
}
|
||||
if ticketFor(tg.sends[0], DetailsLabel) != "" {
|
||||
t.Errorf("an ask that says nothing for Details offers it: %s", labelsOf(tg.sends[0]))
|
||||
}
|
||||
// A kind that proves nothing never shows the whole words, whatever the ask says.
|
||||
a = proposalAsk(c, "s-3")
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "desktop"); whole != "" {
|
||||
t.Errorf("the desk is given the whole words: %q", whole)
|
||||
}
|
||||
if whole := h.wholeOf(h.open["ask.mesh-controller.s-3"], "telegram"); whole != a.Whole {
|
||||
t.Errorf("the phone is not given the whole words: %q", whole)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret's shape leaves the mesh nowhere: an ask whose whole words carry one is refused as a whole, so the
|
||||
// phone never sees it, with or without the proven sender.
|
||||
func TestAnAskWhoseWholeWordsCarryASecretIsRefused(t *testing.T) {
|
||||
for _, secret := range []string{
|
||||
"+ token: ghp_abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"+ password=hunter2",
|
||||
"+ key: 0123456789abcdef0123456789abcdef",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
} {
|
||||
h, tg, _, c, store, w := newRouter(t)
|
||||
link(h, store, c, "42", c.now().Add(-2*time.Hour))
|
||||
a := proposalAsk(c, "s-4")
|
||||
a.Whole = "+ shares: media=/storage/media\n" + secret
|
||||
submit(t, h, "mesh-controller", a)
|
||||
settle(h, c)
|
||||
got := w.of("mesh-controller")
|
||||
if len(tg.sends) != 0 || len(got) != 1 || got[0].Outcome != asks.OutcomeRefused || !strings.Contains(got[0].Words, "whole words carry secret") {
|
||||
t.Errorf("%q: sent %d, told %+v", secret, len(tg.sends), got)
|
||||
}
|
||||
if _, kept := store.asks["mesh-controller.s-4"]; kept {
|
||||
t.Errorf("%q: the refused ask was recorded", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ module messenger
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.10
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0 h1:qi0slgoZVexIr/gxfLhN8kJxy2LVvGTHiWuuGBPkfDo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.13-0.20261010132341-4f3efc98e0c0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
@@ -31,6 +31,7 @@ as the operator.
|
||||
| | fixed here |
|
||||
|---|---|
|
||||
| D1 | up to 4096 characters arrive whole; longer is cut and the cut said |
|
||||
| issue 383 | shown formatted, never reworded: the title bold, a key line or a fingerprint in monospace, the chosen line bold; `<`, `>` and `&` escaped first, so nothing in the words is read as markup |
|
||||
| D4 | a warning or a clearing arrives without sound; an urgent message or a question rings |
|
||||
| D5 | a rate limit is waited out as told |
|
||||
| D6 | a refusal is a permanent failure, said to the router and not retried; an edit to the same text is a success |
|
||||
|
||||
@@ -249,9 +249,10 @@ func keyboard(buttons [][2]string) map[string]any {
|
||||
return map[string]any{"inline_keyboard": rows}
|
||||
}
|
||||
|
||||
// Send posts a message and answers its id. silent: it arrives without sound (D4).
|
||||
// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent:
|
||||
// it arrives without sound (D4).
|
||||
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
|
||||
body := map[string]any{"chat_id": chat, "text": text, "disable_notification": silent,
|
||||
body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent,
|
||||
"link_preview_options": map[string]any{"is_disabled": true}}
|
||||
if len(buttons) > 0 {
|
||||
body["reply_markup"] = keyboard(buttons)
|
||||
@@ -261,9 +262,10 @@ func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (i
|
||||
return out.MessageID, err
|
||||
}
|
||||
|
||||
// Edit replaces a message's text and buttons; none given, the buttons go.
|
||||
// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the
|
||||
// buttons go.
|
||||
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
|
||||
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text,
|
||||
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode,
|
||||
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
|
||||
if notModified(err) {
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,9 @@ package main
|
||||
//
|
||||
// - **Work** comes from its kind's worker on the `channel` queue: `show` (a message with buttons, each
|
||||
// carrying only the router's ticket), `send` (words), `edit` (a message shown before, by the router's
|
||||
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). A warning or a
|
||||
// handle). Up to 4096 characters arrive whole; longer is cut and the cut said (D1). The words are shown
|
||||
// as the router gave them, formatted (render.go): the title bold, a key line or a fingerprint in
|
||||
// monospace, escaped so that none of them is read as markup (issue 383). A warning or a
|
||||
// clearing arrives without sound, an urgent message or a question rings (D4). A rate limit is waited out
|
||||
// as told (D5); a refusal is a permanent failure, said to the router, never retried; an edit to the same
|
||||
// text is a success (D6). A message that cannot be delivered after three tries is said as failed. A
|
||||
@@ -155,7 +157,9 @@ func (c *Channel) init() {
|
||||
}
|
||||
}
|
||||
|
||||
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
|
||||
// text is a message's plain words, cut at MaxLength and the cut said (D1): what the digest of a shown message
|
||||
// is over, and what render makes the chat's text from. The bound is on the words, as Telegram's is on the text
|
||||
// once its markup is read.
|
||||
func text(m asks.Message) string {
|
||||
t := m.Title
|
||||
if m.Body != "" {
|
||||
@@ -253,7 +257,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
||||
var id int64
|
||||
switch {
|
||||
case place != nil:
|
||||
err = c.Bot.Edit(place.Chat, place.ID, words, buttons)
|
||||
err = c.Bot.Edit(place.Chat, place.ID, render(words), buttons)
|
||||
if err == nil || notModified(err) {
|
||||
c.keepShown(place.Chat, place.ID, words)
|
||||
}
|
||||
@@ -297,13 +301,14 @@ func (c *Channel) replaced(p Placed) {
|
||||
return
|
||||
}
|
||||
c.Logf("[telegram] an earlier message could not be deleted (%v); its buttons are taken away instead", err)
|
||||
if err := c.Bot.Edit(p.Chat, p.ID, "Replaced by a newer message below.", nil); err != nil && !gone(err) {
|
||||
if err := c.Bot.Edit(p.Chat, p.ID, escape("Replaced by a newer message below."), nil); err != nil && !gone(err) {
|
||||
c.Logf("[telegram] an earlier message could not be marked replaced either: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain.
|
||||
func (c *Channel) newMessage(handle string, chat int64, words string, buttons [][2]string, silent bool, id *int64) error {
|
||||
got, err := c.Bot.Send(chat, words, buttons, silent)
|
||||
got, err := c.Bot.Send(chat, render(words), buttons, silent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -461,7 +466,7 @@ func (c *Channel) remember(id string, chat int64) {
|
||||
}
|
||||
|
||||
func (c *Channel) reply(chat int64, words string) {
|
||||
if _, err := c.Bot.Send(chat, words, nil, true); err != nil {
|
||||
if _, err := c.Bot.Send(chat, escape(words), nil, true); err != nil {
|
||||
c.Logf("[telegram] could not answer in the chat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestAQuestionIsShownWithItsTicketsAndRingsAndAWarningIsSilent(t *testing.T)
|
||||
}
|
||||
b := sent[0].Body
|
||||
kb, _ := json.Marshal(b["reply_markup"])
|
||||
if b["chat_id"] != float64(42) || b["text"] != "Delivery of hq held\nNeeds you: release it." || b["disable_notification"] != false ||
|
||||
if b["chat_id"] != float64(42) || b["text"] != "<b>Delivery of hq held</b>\nNeeds you: release it." || b["disable_notification"] != false ||
|
||||
string(kb) != `{"inline_keyboard":[[{"callback_data":"0123456789abcdef0123456789abcdef","text":"Release"}]]}` {
|
||||
t.Errorf("sent %v %s", b, kb)
|
||||
}
|
||||
@@ -230,12 +230,61 @@ func TestALongMessageIsCutAndTheCutSaid(t *testing.T) {
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := f.of("sendMessage")[0].Body["text"].(string)
|
||||
// The bound is on the words, as Telegram's is on the text once its markup is read: the tags do not count.
|
||||
got := strings.NewReplacer("<b>", "", "</b>", "").Replace(f.of("sendMessage")[0].Body["text"].(string))
|
||||
if n := len([]rune(got)); n != MaxLength || !strings.HasSuffix(got, cutNote) {
|
||||
t.Errorf("%d characters, ends %q (D1)", n, got[len(got)-40:])
|
||||
}
|
||||
}
|
||||
|
||||
// A message is shown formatted, never reworded (issue 383): the title bold, a key line and a fingerprint in
|
||||
// monospace, the chosen line bold, and every '<', '>' and '&' escaped, so a message carrying one neither breaks
|
||||
// nor injects. The tap on it is still compared by the plain words, which is what Telegram gives back.
|
||||
func TestAMessageIsRenderedInHTMLWithItsMarkupEscaped(t *testing.T) {
|
||||
c, f, s, _ := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Show, asks.Message{Handle: "q1", Title: "Set mounts on <shanks> & co?",
|
||||
Body: "+ shares: media=/storage/media\n~ sources: a=b (was: c)\nProposed by an agent at 15:11.\nFingerprint 1234 5678 9abc.",
|
||||
To: "42", Buttons: []asks.Button{{Label: "Approve", Ticket: "tkt"}}})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := f.of("sendMessage")
|
||||
want := "<b>Set mounts on <shanks> & co?</b>\n<code>+ shares: media=/storage/media</code>\n<code>~ sources: a=b (was: c)</code>\n" +
|
||||
"Proposed by an agent at 15:11.\nFingerprint <code>1234 5678 9abc.</code>"
|
||||
if len(sent) != 1 || sent[0].Body["text"] != want || sent[0].Body["parse_mode"] != "HTML" {
|
||||
t.Fatalf("sent %v", sent)
|
||||
}
|
||||
f.mu.Lock()
|
||||
shown := f.next
|
||||
f.mu.Unlock()
|
||||
// Telegram gives the words back plain: the tap on them is taken.
|
||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
||||
Text: "Set mounts on <shanks> & co?\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\nProposed by an agent at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("the tap on the rendered message was not taken: %s", got)
|
||||
}
|
||||
// The edit says what was chosen, in bold, and is in the same mode.
|
||||
subject, body = work(asks.Edit, asks.Message{Handle: "q1", Title: "You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?",
|
||||
Body: "Told to mesh-controller, which acts on it itself.\n+ shares: media=/storage/media", To: "42"})
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ed := f.of("editMessageText")
|
||||
if len(ed) != 1 || ed[0].Body["parse_mode"] != "HTML" ||
|
||||
ed[0].Body["text"] != "<b>You chose Approve at 15:06 on Telegram · Set mounts on <shanks> & co?</b>\nTold to mesh-controller, which acts on it itself.\n<code>+ shares: media=/storage/media</code>" {
|
||||
t.Errorf("edited %v", ed)
|
||||
}
|
||||
if got := render("Delivery held\nYou chose Release at 13:30 on Telegram.\nTold to mesh-delivery."); got != "<b>Delivery held</b>\n<b>You chose Release at 13:30 on Telegram.</b>\nTold to mesh-delivery." {
|
||||
t.Errorf("the chosen line on a condition's message: %q", got)
|
||||
}
|
||||
// A plain answer in the chat is escaped too.
|
||||
c.reply(42, "a <b>tag</b> & more")
|
||||
if last := f.of("sendMessage"); last[len(last)-1].Body["text"] != "a <b>tag</b> & more" {
|
||||
t.Errorf("a reply was not escaped: %v", last[len(last)-1].Body["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
||||
c, f, s, st := newChannel(t, goodToken)
|
||||
st.Place("m1", Placed{Chat: 42, ID: 7})
|
||||
@@ -528,7 +577,7 @@ func TestATapTheRouterIsSlowOnClaimsNothingAndItsWordComesInTheChat(t *testing.T
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "You chose Release: recorded." {
|
||||
if sent := f.of("sendMessage"); len(sent) != sentBefore+1 || sent[len(sent)-1].Body["text"] != "<b>You chose Release: recorded.</b>" {
|
||||
t.Errorf("the late word was not said in the chat: %v", sent)
|
||||
}
|
||||
// Telegram refuses an acknowledgement that came too late: the word goes to the chat instead.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
// How a message looks in the chat (novox/hq issue 383, the operator's word of 2026-10-10: "no bold text, no
|
||||
// formatting"). The router's words are shown as given — `exact-render` — and formatted, never reworded: the
|
||||
// title in bold, a line that is a key with its value (as a settings proposal lists them, "+ key: value") or a
|
||||
// fingerprint in monospace, and the line that says what was chosen in bold. Every character Telegram's HTML
|
||||
// would read as markup is escaped first, so a message carrying '<' neither breaks nor injects.
|
||||
//
|
||||
// What the operator taps on is compared by its plain words (wordsDigest): Telegram gives a message's text back
|
||||
// without its formatting, so the digest is over the words before they are rendered.
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseMode is the formatting the Bot API is told the text is in.
|
||||
const ParseMode = "HTML"
|
||||
|
||||
var (
|
||||
keyLine = regexp.MustCompile(`^[+~=-] `)
|
||||
fingerprintLine = regexp.MustCompile(`^(Fingerprint )(.+)$`)
|
||||
chosenLine = regexp.MustCompile(`^(You|Somebody) chose `)
|
||||
)
|
||||
|
||||
// escape is a text with the characters Telegram's HTML reads as markup written as entities.
|
||||
func escape(s string) string {
|
||||
return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)
|
||||
}
|
||||
|
||||
// render is a message's plain words (text) as the chat shows them: the first line, the title, in bold; each
|
||||
// line after it as it is, a key line or a fingerprint in monospace, the chosen line in bold.
|
||||
func render(words string) string {
|
||||
lines := strings.Split(words, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
switch {
|
||||
case i == 0:
|
||||
if strings.TrimSpace(line) == "" {
|
||||
out = append(out, "")
|
||||
continue
|
||||
}
|
||||
out = append(out, "<b>"+escape(line)+"</b>")
|
||||
case keyLine.MatchString(line):
|
||||
out = append(out, "<code>"+escape(line)+"</code>")
|
||||
case fingerprintLine.MatchString(line):
|
||||
m := fingerprintLine.FindStringSubmatch(line)
|
||||
out = append(out, escape(m[1])+"<code>"+escape(m[2])+"</code>")
|
||||
case chosenLine.MatchString(line):
|
||||
out = append(out, "<b>"+escape(line)+"</b>")
|
||||
default:
|
||||
out = append(out, escape(line))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user