Files
mesh-lab/asks/faketelegram_test.go
T
jochen dcaf843b8a
mesh/merge-gate head dcaf843b8a6e merged into trunk 1316a405a630 — pass: the change touches no module of the mesh's graph
mesh/repo-check head dcaf843b8a6e merged into trunk 1316a405a630 — pass: THE CHANGE ALTERS ITS OWN CHECK (asks/merge-check.sh): main's version judged it; t…
mesh/delivery delivered
The lab's phone shows an HTML message as Telegram does, so a tap carries back the words shown (issue 692)
Since mesh-catalog #278 the channel sends every message in HTML (issues 383, 639). The fake Bot API kept
the text as sent, tags and entities included, and gave that back on a tap; the channel compares a tap by
the plain words it showed, so the proof's tap was refused as "this message does not show what the mesh put
there". Telegram reads the markup and gives the plain words back; the fake now does too.
2026-10-14 02:37:06 +02:00

352 lines
11 KiB
Go

package asks
import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// fakeTelegram is the Bot API as the channel uses it, and nothing more: the lab's phone. It holds the updates
// the lab makes happen (a message typed, a button tapped) for the channel's long poll, and keeps every message
// the bot sends or edits, by chat, so the lab reads what the operator would see. It never reaches Telegram.
type fakeTelegram struct {
t *testing.T
server *httptest.Server
token string
mu sync.Mutex
updates []map[string]any
next int64
arrived chan struct{}
sent []tgMessage
msgID int64
taps int // answerCallbackQuery calls
deleted int
// toasts are the words a tap's acknowledgement showed over the chat (answerCallbackQuery), by the chat the
// tap came from: what the phone shows the tapper and never puts in the chat. Kept apart from sent, so a
// chat's messages stay what the chat holds.
toasts []tgMessage
tappedIn map[string]int64 // a tap's callback id to the chat it came from
}
// tgMessage is one message the bot sent or edited: its chat, its id, its words and its buttons (label →
// callback data).
type tgMessage struct {
Chat int64
ID int64
Text string
Buttons [][2]string
Edited bool
At time.Time
}
func newFakeTelegram(t *testing.T, token string) *fakeTelegram {
f := &fakeTelegram{t: t, token: token, next: 1, arrived: make(chan struct{}, 1)}
f.server = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.server.Close)
return f
}
func (f *fakeTelegram) URL() string { return f.server.URL }
func (f *fakeTelegram) serve(w http.ResponseWriter, r *http.Request) {
prefix := "/bot" + f.token + "/"
if !strings.HasPrefix(r.URL.Path, prefix) {
// A wrong token is what the real API answers 401 to.
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 401, "description": "Unauthorized"})
return
}
method := strings.TrimPrefix(r.URL.Path, prefix)
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
ok := func(result any) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result}) }
switch method {
case "getMe":
ok(map[string]any{"id": 1000, "is_bot": true, "first_name": "Lab", "username": "lab_mesh_bot"})
case "getUpdates":
offset := int64(num(body["offset"]))
wait := time.Duration(num(body["timeout"])) * time.Second
if wait > 2*time.Second {
wait = 2 * time.Second // the lab's phone answers quickly; the channel polls again at once
}
deadline := time.Now().Add(wait)
for {
f.mu.Lock()
var out []map[string]any
for _, u := range f.updates {
if int64(num(u["update_id"])) >= offset {
out = append(out, u)
}
}
f.mu.Unlock()
if len(out) > 0 {
f.t.Logf("phone: getUpdates from %d hands %d update(s)", offset, len(out))
}
if len(out) > 0 || time.Now().After(deadline) {
ok(orEmpty(out))
return
}
select {
case <-f.arrived:
case <-time.After(time.Until(deadline)):
}
}
case "sendMessage", "editMessageText":
chat := int64(num(body["chat_id"]))
text, _ := body["text"].(string)
if mode, _ := body["parse_mode"].(string); mode == "HTML" {
// What a message shows, and what a tap on it carries back, is its text with the markup read: tags gone,
// entities unescaped — as the service gives it (novox/hq issue 692).
text = shownOfHTML(text)
}
m := tgMessage{Chat: chat, Text: text, Buttons: buttonsOf(body["reply_markup"]), At: time.Now()}
f.mu.Lock()
if method == "sendMessage" {
f.msgID++
m.ID = f.msgID
} else {
m.ID, m.Edited = int64(num(body["message_id"])), true
}
f.sent = append(f.sent, m)
f.mu.Unlock()
f.t.Logf("phone: %s to %d (#%d): %q %v", method, chat, m.ID, firstLine(text), m.Buttons)
ok(map[string]any{"message_id": m.ID, "chat": map[string]any{"id": chat, "type": "private"}, "text": text})
case "answerCallbackQuery":
id, _ := body["callback_query_id"].(string)
text, _ := body["text"].(string)
f.mu.Lock()
f.taps++
chat, known := f.tappedIn[id]
if known && text != "" {
f.toasts = append(f.toasts, tgMessage{Chat: chat, Text: text, At: time.Now()})
}
f.mu.Unlock()
if text != "" {
f.t.Logf("phone: answerCallbackQuery %s in %d: %q", id, chat, firstLine(text))
}
ok(true)
case "deleteMessage":
f.mu.Lock()
f.deleted++
f.mu.Unlock()
ok(true)
default:
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error_code": 404, "description": "Not Found: " + method})
}
}
// push makes one thing happen on the phone, for the channel's next poll.
func (f *fakeTelegram) push(u map[string]any) {
f.mu.Lock()
u["update_id"] = f.next
f.next++
f.updates = append(f.updates, u)
f.mu.Unlock()
select {
case f.arrived <- struct{}{}:
default:
}
}
// typed is a message an account writes in a chat with the bot; private when the chat is the account's own.
func (f *fakeTelegram) typed(from int64, chat int64, chatType, text string) {
f.push(map[string]any{"message": map[string]any{"message_id": 9000 + f.nextID(), "text": text,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"chat": map[string]any{"id": chat, "type": chatType}}})
}
// tapped is a button tapped on a message the bot sent, carrying the button's data and the message's words as
// the service gives them: the words the message shows now (its last send or edit), as on the phone.
func (f *fakeTelegram) tapped(from int64, chat int64, chatType string, onMessage int64, data string) {
f.tappedOn(from, chat, chatType, onMessage, data, f.textOf(onMessage))
}
// tappedOn is a tap on a message showing the words given: what a message changed under the operator's thumb,
// or forwarded and edited elsewhere, carries.
func (f *fakeTelegram) tappedOn(from int64, chat int64, chatType string, onMessage int64, data, text string) {
id := fmt.Sprintf("cb-%d", f.nextID())
f.mu.Lock()
if f.tappedIn == nil {
f.tappedIn = map[string]int64{}
}
f.tappedIn[id] = chat
f.mu.Unlock()
f.push(map[string]any{"callback_query": map[string]any{"id": id, "data": data,
"from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)},
"message": map[string]any{"message_id": onMessage, "text": text,
"chat": map[string]any{"id": chat, "type": chatType}}}})
}
// textOf is what a message the bot sent shows now.
func (f *fakeTelegram) textOf(id int64) string {
f.mu.Lock()
defer f.mu.Unlock()
text := ""
for _, m := range f.sent {
if m.ID == id {
text = m.Text
}
}
return text
}
func (f *fakeTelegram) nextID() int64 {
f.mu.Lock()
defer f.mu.Unlock()
f.msgID++
return f.msgID
}
// waitFor waits for a message to a chat that satisfies match, sent after since.
func (f *fakeTelegram) waitFor(what string, chat int64, since time.Time, within time.Duration, match func(tgMessage) bool) tgMessage {
f.t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
f.mu.Lock()
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(since) && match(m) {
f.mu.Unlock()
return m
}
}
f.mu.Unlock()
time.Sleep(100 * time.Millisecond)
}
f.t.Fatalf("the phone never showed %s (chat %d) within %s; it showed:\n%s", what, chat, within, f.transcript())
return tgMessage{}
}
// toldTapper waits for words the phone showed in a chat at or after a time, as a message in it or as a tap's
// acknowledgement over it: where the channel says the router's word on a tap (issue 372).
func (f *fakeTelegram) toldTapper(what string, chat int64, since time.Time, within time.Duration, match func(string) bool) string {
f.t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
f.mu.Lock()
for _, m := range append(append([]tgMessage{}, f.sent...), f.toasts...) {
if m.Chat == chat && !m.At.Before(since) && match(m.Text) {
f.mu.Unlock()
return m.Text
}
}
f.mu.Unlock()
time.Sleep(100 * time.Millisecond)
}
f.t.Fatalf("the phone never showed %s (chat %d) within %s; it showed:\n%s", what, chat, within, f.transcript())
return ""
}
// since is every message to a chat sent at or after a time.
func (f *fakeTelegram) since(chat int64, at time.Time) []tgMessage {
f.mu.Lock()
defer f.mu.Unlock()
var out []tgMessage
for _, m := range f.sent {
if m.Chat == chat && !m.At.Before(at) {
out = append(out, m)
}
}
return out
}
func (f *fakeTelegram) transcript() string {
f.mu.Lock()
defer f.mu.Unlock()
var b strings.Builder
for _, m := range f.sent {
fmt.Fprintf(&b, " %s to %d #%d edited=%v %q %v\n", m.At.Format("15:04:05.000"), m.Chat, m.ID, m.Edited,
firstLine(m.Text), m.Buttons)
}
for _, m := range f.toasts {
fmt.Fprintf(&b, " %s to %d tap acknowledged %q\n", m.At.Format("15:04:05.000"), m.Chat, firstLine(m.Text))
}
return b.String()
}
// button is the data of the button labelled so on a message.
func button(m tgMessage, label string) string {
for _, b := range m.Buttons {
if b[0] == label {
return b[1]
}
}
return ""
}
func labels(m tgMessage) []string {
var out []string
for _, b := range m.Buttons {
out = append(out, b[0])
}
return out
}
func buttonsOf(markup any) [][2]string {
var out [][2]string
m, _ := markup.(map[string]any)
rows, _ := m["inline_keyboard"].([]any)
for _, row := range rows {
cells, _ := row.([]any)
for _, c := range cells {
cell, _ := c.(map[string]any)
text, _ := cell["text"].(string)
data, _ := cell["callback_data"].(string)
out = append(out, [2]string{text, data})
}
}
return out
}
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case int:
return float64(n)
}
return 0
}
func orEmpty(in []map[string]any) []map[string]any {
if in == nil {
return []map[string]any{}
}
return in
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i] + " …"
}
return s
}
var htmlTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
// shownOfHTML is a text sent in Telegram's HTML as the service shows it and gives it back: its tags read and taken
// out, its entities written as the characters they stand for.
func shownOfHTML(text string) string {
return html.UnescapeString(htmlTag.ReplaceAllString(text, ""))
}
// The fake phone shows a message sent in HTML as Telegram does (novox/hq issue 692): the words without their
// markup, so a tap carries back what the channel digests, as on the real service.
func TestTheFakePhoneShowsHTMLAsTelegramDoes(t *testing.T) {
in := "<b>Flip the lab&#39;s switch?</b>\nA &lt;b&gt; &amp; c\n<code>+ k: v</code>\n\n<blockquote expandable>Asked by lab-asker.</blockquote>"
want := "Flip the lab's switch?\nA <b> & c\n+ k: v\n\nAsked by lab-asker."
if got := shownOfHTML(in); got != want {
t.Errorf("%q, want %q", got, want)
}
}