package asks import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "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 } // 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) 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": f.mu.Lock() f.taps++ f.mu.Unlock() 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) { f.push(map[string]any{"callback_query": map[string]any{"id": fmt.Sprintf("cb-%d", f.nextID()), "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{} } // 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) } 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 }