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
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.
352 lines
11 KiB
Go
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's switch?</b>\nA <b> & 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)
|
|
}
|
|
}
|