package main import ( "bytes" "context" "encoding/json" "net/http" "strings" "testing" "time" "lchat/internal/bot" "lchat/internal/e2ee" ) // postTypedAs posts a typed (non-chat) protocol payload — flow.state here. func postTypedAs(t *testing.T, tsURL string, sess *bot.Session, threadID string, threadKey []byte, payload map[string]any) { t.Helper() payload["ts"] = time.Now().UnixMilli() blob, err := e2ee.EncryptJSON(threadKey, payload) if err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]string{"thread_id": threadID, "blob": blob}) resp, err := http.Post(tsURL+"/api/messages?token="+sess.Token, "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("post typed: %d", resp.StatusCode) } } // TestFlowStateMutesBot pins the one-Eleven handshake: while a client-run // scripted flow holds the floor in a DM (flow.state active), the bot's brain // stays silent — the member's typed answers belong to the script. Ceding the // floor un-mutes it. Ordered processing proves the silence: replies come in // message order, so if the muted "hello" had been answered, its reply would // precede /help's. func TestFlowStateMutesBot(t *testing.T) { ts, s := newTestServer(t) owner := provisionNamed(t, ts.URL, "Owner", false) eleven := provisionNamed(t, ts.URL, "Eleven", true) dmID, dmKey := createThreadKind(t, ts.URL, s, "dm", owner, eleven) ready := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() rt := &bot.Runtime{Base: ts.URL, Session: eleven, Handler: bot.CommandFloor("Eleven", false), Ready: func() { close(ready) }, Logf: t.Logf} go rt.Run(ctx) awaitReady(t, ready) // The script takes the floor; the member's answer must get NO reply. postTypedAs(t, ts.URL, owner, dmID, dmKey, map[string]any{"type": "flow.state", "active": true}) postAs(t, ts.URL, owner, dmID, dmKey, "Paul") // a scripted answer, not a prompt // The script cedes; a real question gets its reply. postTypedAs(t, ts.URL, owner, dmID, dmKey, map[string]any{"type": "flow.state", "active": false}) postAs(t, ts.URL, owner, dmID, dmKey, "/help") waitFor(t, "post-flow reply", func() bool { return len(botReplies(t, s, dmID, dmKey, "Eleven")) > 0 }) replies := botReplies(t, s, dmID, dmKey, "Eleven") if len(replies) != 1 { t.Fatalf("got %d replies, want 1 (the mid-flow answer must be ignored): %+v", len(replies), replies) } if !strings.Contains(replies[0].Text, "/theme") { t.Fatalf("the one reply should be /help's: %+v", replies[0]) } }