9d58922462
The push half accepts an @channelusername and the intake half cannot: an
inbound update names its chat by number, so an @-name matches nothing. The
check lived in NewPoller, which wireTelegramIntake logs and returns from, so a
box configured that way booted clean with a dead intake half and a working push
half. Nothing looked broken from the chat.
ValidateIntakeChatID moves the rule where config validation can reach it, the
same shape validateNetScan uses. It is stricter than the old prefix test: any
non-digit is refused, not just a leading @. An empty token or chat id still
means telegram is not wired, because an unset ${TELEGRAM_*} expands to empty
and that must not fail a box with no bot.
deploy/mavend.json turns intake on. The chat id on this box is numeric.
The onCallback comment claimed every path answers the callback. The fromOwner
early return does not, and silence toward a stranger is correct, so the comment
was what was wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
7.3 KiB
Go
217 lines
7.3 KiB
Go
package telegramsink
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The turn he types in the chat is the turn the web would run, and the reply
|
|
// carries the one gesture beside it.
|
|
func TestIntakeRunsTheTurnAndOffersTheCorrection(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
rec := &recorder{reply: "поняла", traceID: 91}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.handle(context.Background(), msg(ownerChat, " поужинал "))
|
|
|
|
if got := rec.took(); len(got) != 1 || got[0] != "поужинал" {
|
|
t.Fatalf("turns %q, want the trimmed utterance once", got)
|
|
}
|
|
// The dialogue is keyed per chat, so a clarify asked here is not answered on
|
|
// the web.
|
|
if rec.conversation != "telegram:"+ownerChat {
|
|
t.Errorf("conversation %q does not name the chat", rec.conversation)
|
|
}
|
|
sends := b.called("sendMessage")
|
|
if len(sends) != 1 {
|
|
t.Fatalf("%d sends, want 1", len(sends))
|
|
}
|
|
if sends[0].body["text"] != "поняла" {
|
|
t.Errorf("sent %v, want the reply", sends[0].body["text"])
|
|
}
|
|
if sends[0].body["protect_content"] != true {
|
|
t.Error("his own data went out forwardable")
|
|
}
|
|
kb, _ := json.Marshal(sends[0].body["reply_markup"])
|
|
if !strings.Contains(string(kb), "w:91") {
|
|
t.Errorf("keyboard %s does not point at the turn's trace", kb)
|
|
}
|
|
}
|
|
|
|
// A turn nothing persisted has no row to correct, and a button that would name
|
|
// one reports a failure he cannot act on.
|
|
func TestIntakeSkipsTheGestureWithNoTrace(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
p := newTestPoller(t, b, &recorder{reply: "поняла", traceID: 0})
|
|
|
|
p.handle(context.Background(), msg(ownerChat, "привет"))
|
|
|
|
sends := b.called("sendMessage")
|
|
if len(sends) != 1 {
|
|
t.Fatalf("%d sends, want 1", len(sends))
|
|
}
|
|
if _, ok := sends[0].body["reply_markup"]; ok {
|
|
t.Error("offered a correction on a turn with no trace")
|
|
}
|
|
}
|
|
|
|
// One chat, and a stranger is not answered at all: a reply confirms the bot
|
|
// exists and whose it is.
|
|
func TestIntakeIgnoresAnyOtherChat(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
rec := &recorder{reply: "поняла", traceID: 5}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.handle(context.Background(), msg("9999", "включи свет"))
|
|
p.handle(context.Background(), update{UpdateID: 8, CallbackQuery: &callbackQuery{
|
|
ID: "cb", Data: "t:5:note", Message: message{Chat: chat{ID: json.Number("9999")}},
|
|
}})
|
|
|
|
if got := rec.took(); len(got) != 0 {
|
|
t.Errorf("ran %q for a chat that is not the owner's", got)
|
|
}
|
|
if len(rec.corrections) != 0 {
|
|
t.Errorf("wrote %v from a chat that is not the owner's", rec.corrections)
|
|
}
|
|
if len(b.calls) != 0 {
|
|
t.Errorf("answered a stranger: %v", b.calls)
|
|
}
|
|
}
|
|
|
|
// Tapping "не то" opens the seven and writes nothing yet. The target is worth
|
|
// much more than the negative, so it must not be spent before he can name it.
|
|
func TestIntakeFirstTapOnlyOpensTheTargets(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
rec := &recorder{}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
|
ID: "cb", Data: "w:77", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
|
}})
|
|
|
|
if len(rec.corrections) != 0 {
|
|
t.Fatalf("wrote %v before he named a target", rec.corrections)
|
|
}
|
|
if len(b.called("answerCallbackQuery")) != 1 {
|
|
t.Error("left the clock spinning on the button")
|
|
}
|
|
edits := b.called("editMessageReplyMarkup")
|
|
if len(edits) != 1 {
|
|
t.Fatalf("%d edits, want the target row", len(edits))
|
|
}
|
|
kb, _ := json.Marshal(edits[0].body["reply_markup"])
|
|
for _, want := range CorrectionTargets {
|
|
if !strings.Contains(string(kb), `"`+want+`"`) {
|
|
t.Errorf("target row %s is missing %s", kb, want)
|
|
}
|
|
}
|
|
// And the way out, because he opened the row without knowing he had to name
|
|
// anything.
|
|
if !strings.Contains(string(kb), `"t:77:"`) {
|
|
t.Errorf("target row %s prices out the untargeted negative", kb)
|
|
}
|
|
}
|
|
|
|
func TestIntakeWritesTheCorrection(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, data, want string
|
|
}{
|
|
{"with a target", "t:77:note", "note"},
|
|
{"untargeted", "t:77:", ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
rec := &recorder{}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
|
ID: "cb", Data: tc.data, Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
|
}})
|
|
|
|
if len(rec.corrections) != 1 || rec.corrections[0] != (correction{77, tc.want}) {
|
|
t.Fatalf("corrections %v, want trace 77 → %q", rec.corrections, tc.want)
|
|
}
|
|
// The buttons come off once the gesture is given.
|
|
edits := b.called("editMessageReplyMarkup")
|
|
if len(edits) != 1 {
|
|
t.Fatalf("%d edits, want the keyboard cleared", len(edits))
|
|
}
|
|
kb, _ := json.Marshal(edits[0].body["reply_markup"])
|
|
if strings.Contains(string(kb), "t:77") {
|
|
t.Errorf("keyboard %s still invites a second correction", kb)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A write that failed says so on the button. Silence would read as recorded.
|
|
func TestIntakeSaysWhenTheLabelDidNotLand(t *testing.T) {
|
|
b := newFakeBot(t)
|
|
rec := &recorder{correctErr: errors.New("no such routing trace")}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
|
ID: "cb", Data: "t:77:fact", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
|
}})
|
|
|
|
answers := b.called("answerCallbackQuery")
|
|
if len(answers) != 1 || answers[0].body["text"] == "" {
|
|
t.Fatalf("answers %v, want a toast saying it did not land", answers)
|
|
}
|
|
if len(b.called("editMessageReplyMarkup")) != 0 {
|
|
t.Error("cleared the buttons after a failed write, so he cannot try again")
|
|
}
|
|
}
|
|
|
|
// A question asked while the daemon was down has been answered by him or has
|
|
// stopped mattering, and a reminder set from it would land at the wrong time.
|
|
func TestIntakeDiscardsTheBacklog(t *testing.T) {
|
|
b := newFakeBot(t, []update{msg(ownerChat, "напомни в 7 позвонить маме")})
|
|
rec := &recorder{reply: "поняла", traceID: 3}
|
|
p := newTestPoller(t, b, rec)
|
|
|
|
p.discardBacklog(context.Background())
|
|
|
|
if got := rec.took(); len(got) != 0 {
|
|
t.Errorf("answered %q from the overnight queue", got)
|
|
}
|
|
// And the offset moved past it, so the next poll does not see it again.
|
|
if p.offset != 8 {
|
|
t.Errorf("offset %d, want the skipped update acknowledged", p.offset)
|
|
}
|
|
}
|
|
|
|
// The poller does not start without somewhere to send the turn.
|
|
func TestNewPollerNeedsATurn(t *testing.T) {
|
|
sink, err := New(Config{BotToken: "t", ChatID: ownerChat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewPoller(sink, nil, nil); err == nil {
|
|
t.Error("built a poller that reads the chat and answers nothing")
|
|
}
|
|
if _, err := NewPoller(nil, func(context.Context, string, string) (string, int64, error) {
|
|
return "", 0, nil
|
|
}, nil); err == nil {
|
|
t.Error("built a poller with no sink to answer through")
|
|
}
|
|
}
|
|
|
|
// A chat id the intake half cannot match is refused before anything reads the
|
|
// chat. Config validation calls the same check, so this is the boot error.
|
|
func TestValidateIntakeChatID(t *testing.T) {
|
|
for _, ok := range []string{"123", "-1001234567890", " 42 "} {
|
|
if err := ValidateIntakeChatID(ok); err != nil {
|
|
t.Errorf("ValidateIntakeChatID(%q): %v", ok, err)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", "@maven", "-", "12a", "1 2"} {
|
|
if err := ValidateIntakeChatID(bad); err == nil {
|
|
t.Errorf("ValidateIntakeChatID(%q) accepted; want error", bad)
|
|
}
|
|
}
|
|
}
|