mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
283 lines
7.9 KiB
Go
283 lines
7.9 KiB
Go
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
|
|
}
|