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
The bus the controller composes and raises, at its commit, for one machine; the mesh's runtime serving the router and the Telegram channel on their own credentials; a fake Bot API as the phone. It links the operator's account with the code shown on the desk, refuses an approval in the hour after a link, performs an approval exactly once with who and how in the warrant and the router's record, refuses a replayed tap and a redelivered warrant, tells the operator when another account answers, drops a tap from a group, does nothing on Decline and on expiry, offers no approval while an agent can become root where the channel runs, and has the bus refuse every forged warrant, ask and tap. live-acceptance.sh is the same after rollout, with a drill the operator approves on the phone.
262 lines
7.2 KiB
Go
262 lines
7.2 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 as the service gives it.
|
|
func (f *fakeTelegram) tapped(from int64, chat int64, chatType string, onMessage int64, data 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, "chat": map[string]any{"id": chat, "type": chatType}}}})
|
|
}
|
|
|
|
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
|
|
}
|