An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
This commit is contained in:
@@ -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 in monospace, the note in a
|
||||
// collapsed blockquote after the body, escaped so that none of them is read as markup (issues 383, 639). 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
|
||||
@@ -88,10 +90,18 @@ type Store interface {
|
||||
Show(chat, id int64, digest string) error
|
||||
}
|
||||
|
||||
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — its line
|
||||
// endings and outer spaces aside — so a message whose words another holder of the token changed is told
|
||||
// from the one this channel showed (the review of 2026-10-09).
|
||||
// wordsDigest is what a tap's message is compared by: the words as Telegram gives them back — how the
|
||||
// whitespace between them runs aside, which Telegram may change around a blockquote (issue 639) — so a message
|
||||
// whose words another holder of the token changed is told from the one this channel showed (the review of
|
||||
// 2026-10-09).
|
||||
func wordsDigest(words string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(strings.Fields(words), " ")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// linesDigest is the digest a message shown before issue 639 was kept under: its line endings and outer spaces
|
||||
// aside. A tap on such a message is still compared by it.
|
||||
func linesDigest(words string) string {
|
||||
w := strings.TrimSpace(strings.ReplaceAll(words, "\r\n", "\n"))
|
||||
sum := sha256.Sum256([]byte(w))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@@ -155,20 +165,6 @@ func (c *Channel) init() {
|
||||
}
|
||||
}
|
||||
|
||||
// text is a message as the chat shows it, cut at MaxLength and the cut said (D1).
|
||||
func text(m asks.Message) string {
|
||||
t := m.Title
|
||||
if m.Body != "" {
|
||||
t += "\n" + m.Body
|
||||
}
|
||||
if utf8.RuneCountInString(t) <= MaxLength {
|
||||
return t
|
||||
}
|
||||
runes := []rune(t)
|
||||
keep := MaxLength - utf8.RuneCountInString(cutNote)
|
||||
return string(runes[:keep]) + cutNote
|
||||
}
|
||||
|
||||
// buttons are a message's buttons as the API takes them: a label and the ticket, which must fit the 64
|
||||
// bytes Telegram allows; one that does not is left out and said.
|
||||
func (c *Channel) buttons(m asks.Message) [][2]string {
|
||||
@@ -231,7 +227,7 @@ func (c *Channel) Take(subject string, body json.RawMessage) error {
|
||||
c.fail(m.Handle, err, true)
|
||||
return nil
|
||||
}
|
||||
words, buttons := text(m), c.buttons(m)
|
||||
said, buttons := partsOf(m), c.buttons(m)
|
||||
silent := m.Silent || (!m.Urgent && len(buttons) == 0)
|
||||
// **A message said again under a handle already shown replaces it** (issue 373): a reminder or an
|
||||
// escalation is one message per problem, not one more beside it. An edit here notifies nobody, so this one
|
||||
@@ -253,15 +249,15 @@ 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, said.html(), buttons)
|
||||
if err == nil || notModified(err) {
|
||||
c.keepShown(place.Chat, place.ID, words)
|
||||
c.keepShown(place.Chat, place.ID, said.plain())
|
||||
}
|
||||
if gone(err) {
|
||||
place, err = nil, c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||
place, err = nil, c.newMessage(m.Handle, chat, said, buttons, silent, &id)
|
||||
}
|
||||
default:
|
||||
err = c.newMessage(m.Handle, chat, words, buttons, silent, &id)
|
||||
err = c.newMessage(m.Handle, chat, said, buttons, silent, &id)
|
||||
}
|
||||
if err == nil {
|
||||
if earlier != nil {
|
||||
@@ -297,13 +293,15 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// newMessage sends words, rendered (render.go), and keeps where they are and what they say, plain.
|
||||
func (c *Channel) newMessage(handle string, chat int64, parts shown, buttons [][2]string, silent bool, id *int64) error {
|
||||
words := parts.plain()
|
||||
got, err := c.Bot.Send(chat, parts.html(), buttons, silent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,7 +351,7 @@ func (c *Channel) Handle(u Update) {
|
||||
// Only on a message showing exactly what this channel put there: one whose words another holder of
|
||||
// the token changed, or one it never showed, answers nothing (the review of 2026-10-09).
|
||||
if want, err := c.Store.Shown(cb.Message.Chat.ID, cb.Message.MessageID); err != nil || want == "" ||
|
||||
want != wordsDigest(cb.Message.Text) {
|
||||
(want != wordsDigest(cb.Message.Text) && want != linesDigest(cb.Message.Text)) {
|
||||
words := "That answer was not taken: this message does not show what the mesh put there. Nothing was done."
|
||||
c.answerTap(cb.ID, words)
|
||||
// Said on the phone alone, never in the chat: the tapper may be somebody the buttons reached, who is told
|
||||
@@ -461,7 +459,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,83 @@ 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))
|
||||
got = strings.ReplaceAll(got, ">", ">")
|
||||
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 (issues 383 and 639): the title bold, a key line in monospace, the
|
||||
// note apart in a collapsed blockquote, 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: "Changes two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)",
|
||||
Note: "Asked by mesh-controller 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>\nChanges two settings.\n<code>+ shares: media=/storage/media</code>\n" +
|
||||
"<code>~ sources: a=b (was: c)</code>\n\n<blockquote expandable>Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc.</blockquote>"
|
||||
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 space around the blockquote as it pleases: 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?\nChanges two settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
"Asked by mesh-controller 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)
|
||||
}
|
||||
// Words changed by another holder of the token are still told apart.
|
||||
c.Handle(Update{UpdateID: 2, Callback: &Callback{ID: "cb2", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: shown, Chat: Chat{ID: 42, Type: "private"},
|
||||
Text: "Set mounts on <shanks> & co?\nChanges three settings.\n+ shares: media=/storage/media\n~ sources: a=b (was: c)\n" +
|
||||
"Asked by mesh-controller at 15:11.\nFingerprint 1234 5678 9abc."}}})
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("a tap on changed words was taken: %s", got)
|
||||
}
|
||||
// The edit says what was chosen, one line with the headline, 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?",
|
||||
Note: "Told to mesh-controller, which acts on it itself.", 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>\n\n<blockquote expandable>Told to mesh-controller, which acts on it itself.</blockquote>" {
|
||||
t.Errorf("edited %v", ed)
|
||||
}
|
||||
// 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"])
|
||||
}
|
||||
}
|
||||
|
||||
// A message shown before issue 639 was kept under the digest of its lines: a tap on it is still taken.
|
||||
func TestATapOnAMessageShownBeforeIsStillTaken(t *testing.T) {
|
||||
c, _, s, st := newChannel(t, goodToken)
|
||||
words := "Delivery held\nRelease or Stop."
|
||||
if err := st.Show(42, 9, linesDigest(words)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Handle(Update{UpdateID: 1, Callback: &Callback{ID: "cb1", Data: "tkt", From: User{ID: 42, FirstName: "Jo"},
|
||||
Message: &Msg{MessageID: 9, Chat: Chat{ID: 42, Type: "private"}, Text: words}}})
|
||||
if got := s.of(asks.IntakeSubject(asks.Chosen, Kind)); len(got) != 1 {
|
||||
t.Fatalf("the tap on a message shown before was not taken: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEditToTheSameTextIsASuccessAndAGoneMessageIsSentAgain(t *testing.T) {
|
||||
c, f, s, st := newChannel(t, goodToken)
|
||||
st.Place("m1", Placed{Chat: 42, ID: 7})
|
||||
@@ -528,7 +599,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,96 @@
|
||||
package main
|
||||
|
||||
// How a message looks in the chat (novox/hq issues 383 and 639, the operator's words: "no bold text, no
|
||||
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
|
||||
// `exact-render` — and formatted, never reworded:
|
||||
//
|
||||
// - the title in bold: the headline, or how an answered ask ended;
|
||||
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
|
||||
// in monospace;
|
||||
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
|
||||
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
|
||||
//
|
||||
// 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"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// ParseMode is the formatting the Bot API is told the text is in.
|
||||
const ParseMode = "HTML"
|
||||
|
||||
var keyLine = regexp.MustCompile(`^[+~=-] `)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// shown is a message's three parts as the chat carries them.
|
||||
type shown struct{ title, body, note string }
|
||||
|
||||
// partsOf is a message's parts, cut so that its plain words fit MaxLength and the cut said (D1): the note is cut
|
||||
// first, then the body. The bound is on the words, as Telegram's is on the text once its markup is read.
|
||||
func partsOf(m asks.Message) shown {
|
||||
s := shown{title: strings.TrimSpace(m.Title), body: strings.TrimSpace(m.Body), note: strings.TrimSpace(m.Note)}
|
||||
over := utf8.RuneCountInString(s.plain()) - MaxLength
|
||||
if over <= 0 {
|
||||
return s
|
||||
}
|
||||
mark := utf8.RuneCountInString(cutNote)
|
||||
cut := func(text string, by int) string {
|
||||
r := []rune(text)
|
||||
keep := max(len(r)-by-mark, 0)
|
||||
return string(r[:keep]) + cutNote
|
||||
}
|
||||
if utf8.RuneCountInString(s.note) > over+mark {
|
||||
s.note = cut(s.note, over)
|
||||
return s
|
||||
}
|
||||
s.note = ""
|
||||
keep := max(MaxLength-utf8.RuneCountInString(s.title)-1-mark, 0)
|
||||
if r := []rune(s.body); keep < len(r) {
|
||||
s.body = string(r[:keep])
|
||||
}
|
||||
s.body += cutNote
|
||||
return s
|
||||
}
|
||||
|
||||
// plain is the words as Telegram gives them back: the title, the body on the lines after it, the note after a
|
||||
// blank line.
|
||||
func (s shown) plain() string {
|
||||
t := s.title
|
||||
if s.body != "" {
|
||||
t += "\n" + s.body
|
||||
}
|
||||
if s.note != "" {
|
||||
t += "\n\n" + s.note
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// html is the same words as the chat shows them.
|
||||
func (s shown) html() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<b>" + escape(s.title) + "</b>")
|
||||
if s.body != "" {
|
||||
for _, line := range strings.Split(s.body, "\n") {
|
||||
b.WriteString("\n")
|
||||
if keyLine.MatchString(line) {
|
||||
b.WriteString("<code>" + escape(line) + "</code>")
|
||||
} else {
|
||||
b.WriteString(escape(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.note != "" {
|
||||
b.WriteString("\n\n<blockquote expandable>" + escape(s.note) + "</blockquote>")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// adr0343 is the ask for ADR 0343 as the router now sends it to this channel (novox/hq issue 639): the knowledge
|
||||
// base's brief as its title and body, the rest in its note. On 2026-10-13 the same ask arrived as about thirty plain
|
||||
// lines — every one of its thirteen clauses on a line of its own — and the operator would not read it.
|
||||
var adr0343 = asks.Message{Handle: "h-343", To: "42",
|
||||
Title: "Your agents' memory follows you to every machine",
|
||||
Body: "What an agent learns in a project's memory is copied to every machine that runs the agent, so a session " +
|
||||
"anywhere starts with it. A file changed on two machines keeps both versions and warns you; secrets in memory files " +
|
||||
"are screened, though not perfectly.\n" +
|
||||
"• Only memory folders travel: links and files over 128 KiB stay where they are\n" +
|
||||
"• A conflict reaches you as a warning that needs you\n" +
|
||||
"• A deleted file is marked, and a newer change elsewhere outlives the deletion",
|
||||
Note: "Asked by mesh-knowledge at 16:05. Unanswered by tomorrow 15:55: the record stays proposed; nothing of it is in force.\n" +
|
||||
"Approve: the record is accepted and its clauses are in force. Decline: the record stays proposed; nothing of it is in force.\n" +
|
||||
"ADR 0343, proposed by fix-367@g14; the knowledge base's own review approved it. Fingerprint a59f 361b f60e cb5e d237 a837.\n" +
|
||||
"Read its 13 clauses with the knowledge base's get, number 343.",
|
||||
Buttons: []asks.Button{{Label: "Approve", Ticket: "t-approve"}, {Label: "Decline", Ticket: "t-decline"}}}
|
||||
|
||||
// The ADR 0343 ask fits a phone's glance: a bold headline, the summary and its points, then the rest apart and
|
||||
// collapsed — at most twelve lines in all, and never a clause.
|
||||
func TestTheADR0343AskFitsAGlance(t *testing.T) {
|
||||
c, f, _, _ := newChannel(t, goodToken)
|
||||
subject, body := work(asks.Show, adr0343)
|
||||
if err := c.Take(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := f.of("sendMessage")
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent %d", len(sent))
|
||||
}
|
||||
html := sent[0].Body["text"].(string)
|
||||
t.Logf("as sent:\n%s", html)
|
||||
lines := strings.Split(html, "\n")
|
||||
if len(lines) > 12 {
|
||||
t.Errorf("the ask is %d lines, more than 12:\n%s", len(lines), html)
|
||||
}
|
||||
if lines[0] != "<b>Your agents' memory follows you to every machine</b>" && lines[0] != "<b>Your agents' memory follows you to every machine</b>" {
|
||||
t.Errorf("the headline is not first and bold: %q", lines[0])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], "What an agent learns") || strings.Count(html, "\n• ") != 3 {
|
||||
t.Errorf("the summary and its three points do not follow the headline:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "\n\n<blockquote expandable>Asked by mesh-knowledge at 16:05.") || !strings.HasSuffix(html, "number 343.</blockquote>") {
|
||||
t.Errorf("the note is not apart and collapsed:\n%s", html)
|
||||
}
|
||||
for _, lead := range []string{"Only the memory folders are carried", "How it is checked", "13 clause(s)", "**"} {
|
||||
if strings.Contains(html, lead) {
|
||||
t.Errorf("the ask carries %q", lead)
|
||||
}
|
||||
}
|
||||
if sent[0].Body["parse_mode"] != "HTML" {
|
||||
t.Errorf("not sent as HTML: %v", sent[0].Body["parse_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// A long note is cut before the body is, and the cut said; a body too long loses the note and is cut itself.
|
||||
func TestALongNoteIsCutBeforeTheBody(t *testing.T) {
|
||||
m := asks.Message{Title: "t", Body: "the summary", Note: strings.Repeat("n", 5000)}
|
||||
s := partsOf(m)
|
||||
if s.body != "the summary" || !strings.HasSuffix(s.note, cutNote) || len([]rune(s.plain())) != MaxLength {
|
||||
t.Errorf("body %q, note ends %q, %d characters", s.body, s.note[len(s.note)-20:], len([]rune(s.plain())))
|
||||
}
|
||||
m.Body = strings.Repeat("b", 5000)
|
||||
s = partsOf(m)
|
||||
if s.note != "" || !strings.HasSuffix(s.body, cutNote) || len([]rune(s.plain())) != MaxLength {
|
||||
t.Errorf("note %d, %d characters", len(s.note), len([]rune(s.plain())))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user