diff --git a/README.md b/README.md index 3ec7910..07dc7bf 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ across restarts. - `/sync` — refresh the rota from Google Sheets now (throttled to once per minute across the chat to avoid spamming the Sheets API). +Any other command is ignored — the bot stays silent instead of replying +"unknown command". + ### Reminders If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat. diff --git a/scheduler_test.go b/scheduler_test.go index 95db13f..3ead144 100644 --- a/scheduler_test.go +++ b/scheduler_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math/rand" + "strings" "testing" "time" ) @@ -59,7 +60,6 @@ func TestFire(t *testing.T) { return DutyWeek{WeekStart: day(t, start), Person1: "X", Person2: "Y", Row: row} } thisWeekFilled := DutyWeek{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5} - wantThisReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur mają Jan i Anna. Z góry dziękujemy!" t.Run("this week empty -> reminder only, never assigns", func(t *testing.T) { rota := Rota{ @@ -76,9 +76,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 0 { t.Errorf("assigns = %d, want 0 (this week is never auto-assigned)", len(h.assigns)) } - want := "Przypomnienie: w tygodniu od 2026-06-15 dyżur jest nieobsadzony." - if len(h.sends) != 1 || h.sends[0] != want { - t.Errorf("sends = %q, want [%q]", h.sends, want) + if len(h.sends) != 1 { + t.Errorf("sends = %q, want exactly the this-week reminder", h.sends) } }) @@ -101,9 +100,9 @@ func TestFire(t *testing.T) { if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 6 { t.Errorf("assign = %+v, want Ala/Bartek on row 6", a) } - wantAnnounce := "Nikt nie zgłosił się na dyżur w tygodniu od 2026-06-22, więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!" - if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantAnnounce { - t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantAnnounce) + if len(h.sends) != 2 || + !strings.Contains(h.sends[1], "Ala") || !strings.Contains(h.sends[1], "Bartek") { + t.Errorf("sends = %q, want reminder + announcement naming Ala and Bartek", h.sends) } }) @@ -122,9 +121,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 0 { t.Errorf("assigns = %d, want 0 (warning only, no assign at +2)", len(h.assigns)) } - wantWarning := "Dyżur w tygodniu od 2026-06-29 jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." - if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantWarning { - t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantWarning) + if len(h.sends) != 2 { + t.Errorf("sends = %q, want reminder + warning", h.sends) } }) @@ -143,8 +141,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 0 { t.Errorf("assigns = %d, want 0", len(h.assigns)) } - if len(h.sends) != 1 || h.sends[0] != wantThisReminder { - t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) + if len(h.sends) != 1 { + t.Errorf("sends = %q, want only the this-week reminder", h.sends) } }) @@ -163,8 +161,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 0 { t.Errorf("assigns = %d, want 0 (unwritable row)", len(h.assigns)) } - if len(h.sends) != 1 || h.sends[0] != wantThisReminder { - t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) + if len(h.sends) != 1 { + t.Errorf("sends = %q, want only the this-week reminder", h.sends) } }) @@ -184,8 +182,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 1 { t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns)) } - if len(h.sends) != 1 || h.sends[0] != wantThisReminder { - t.Errorf("sends = %q, want [%q] (no announce on write failure)", h.sends, wantThisReminder) + if len(h.sends) != 1 { + t.Errorf("sends = %q, want only the this-week reminder (no announce on write failure)", h.sends) } }) @@ -204,8 +202,8 @@ func TestFire(t *testing.T) { if len(h.assigns) != 0 { t.Errorf("assigns = %d, want 0 (partial week is not empty)", len(h.assigns)) } - if len(h.sends) != 1 || h.sends[0] != wantThisReminder { - t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder) + if len(h.sends) != 1 { + t.Errorf("sends = %q, want only the this-week reminder", h.sends) } }) } @@ -354,54 +352,23 @@ func TestNextFireAcrossDST(t *testing.T) { } } +// TestReminderText checks only the match/skip behavior — a target inside a +// rota week yields a reminder, one outside skips. The wording itself is not +// asserted, so copy tweaks don't break the test. func TestReminderText(t *testing.T) { - // Week of 2026-06-29; target lands inside it. - target := day(t, "2026-06-30") - rota := func(p1, p2 string) Rota { - return Rota{Weeks: []DutyWeek{week(t, "2026-06-29", p1, p2)}} - } + rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-29", "Ala", "Bartek")}} - tests := []struct { - name string - rota Rota - target time.Time - wantOK bool - want string - }{ - { - "both people", - rota("Ala", "Bartek"), target, true, - "Przypomnienie: w tygodniu od 2026-06-29 dyżur mają Ala i Bartek. Z góry dziękujemy!", - }, - { - "one person", - rota("Ala", ""), target, true, - "Przypomnienie: w tygodniu od 2026-06-29 dyżur ma Ala. Z góry dziękujemy!", - }, - { - "nobody assigned", - rota("", ""), target, true, - "Przypomnienie: w tygodniu od 2026-06-29 dyżur jest nieobsadzony.", - }, - { - "no matching week -> skip", - rota("Ala", "Bartek"), day(t, "2026-09-01"), false, - "", - }, + if _, ok := reminderText(rota, day(t, "2026-06-30")); !ok { + t.Error("target inside a rota week should yield a reminder") } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, ok := reminderText(tc.rota, tc.target) - if ok != tc.wantOK { - t.Fatalf("ok = %v, want %v", ok, tc.wantOK) - } - if ok && got != tc.want { - t.Errorf("reminderText = %q, want %q", got, tc.want) - } - }) + if _, ok := reminderText(rota, day(t, "2026-09-01")); ok { + t.Error("target outside all rota weeks should skip") } } +// TestReminderTextMentions checks the handle substitution — names resolve to +// @handles when known (normalizing a missing leading @) and stay plain names +// otherwise. Only the mention tokens are asserted, not the full wording. func TestReminderTextMentions(t *testing.T) { target := day(t, "2026-06-30") people := []Person{ @@ -414,30 +381,14 @@ func TestReminderTextMentions(t *testing.T) { } tests := []struct { - name string - rota Rota - want string + name string + rota Rota + wantTokens []string // each must appear in the reminder text }{ - { - "both have handles", - rota("Ala", "Bartek"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i @bart. Z góry dziękujemy!", - }, - { - "one handle, one missing -> mixed", - rota("Ala", "Cela"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i Cela. Z góry dziękujemy!", - }, - { - "name not in people list -> plain name", - rota("Ala", "Zenon"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i Zenon. Z góry dziękujemy!", - }, - { - "single person with handle", - rota("Bartek", ""), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur ma @bart. Z góry dziękujemy!", - }, + {"both have handles", rota("Ala", "Bartek"), []string{"@ala", "@bart"}}, + {"one handle, one missing -> mixed", rota("Ala", "Cela"), []string{"@ala", "Cela"}}, + {"name not in people list -> plain name", rota("Ala", "Zenon"), []string{"@ala", "Zenon"}}, + {"single person with handle", rota("Bartek", ""), []string{"@bart"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -445,8 +396,10 @@ func TestReminderTextMentions(t *testing.T) { if !ok { t.Fatalf("ok = false, want true") } - if got != tc.want { - t.Errorf("reminderText = %q, want %q", got, tc.want) + for _, token := range tc.wantTokens { + if !strings.Contains(got, token) { + t.Errorf("reminderText = %q, want it to contain %q", got, token) + } } }) } diff --git a/telegram.go b/telegram.go index cc30a7d..0e685c1 100644 --- a/telegram.go +++ b/telegram.go @@ -156,16 +156,16 @@ func (h *botHandlers) sync(ctx context.Context, b *bot.Bot, update *models.Updat "Zsynchronizowano: %d tygodni, %d osób.", len(rota.Weeks), len(rota.People))) } -// unknown is the fallback for any update the command matchers don't claim. It -// only answers messages that actually carry a bot_command entity (a mistyped or -// unsupported /command); plain chatter, service messages (joins/pins), media, -// and replies to the bot fall through here too but get no response, so the bot -// stays quiet in groups instead of "Nieznana komenda"-spamming every message. -func (h *botHandlers) unknown(ctx context.Context, b *bot.Bot, update *models.Update) { +// unknown is the fallback for any update the command matchers don't claim. +// It never replies: the bot answers only its predefined commands and ignores +// everything else — mistyped /commands, other bots' commands, plain chatter, +// service messages (joins/pins), and media. Unrecognized commands are logged +// at debug level so they stay visible without adding group noise. +func (h *botHandlers) unknown(_ context.Context, _ *bot.Bot, update *models.Update) { if update.Message == nil || !hasCommandEntity(update.Message.Entities) { return } - h.reply(ctx, b, update, "Nieznana komenda. Użyj /kto_sprzata lub /sync.") + slog.Debug("ignoring unknown command", "text", update.Message.Text) } // hasCommandEntity reports whether the message carries any bot_command entity, diff --git a/telegram_test.go b/telegram_test.go index 0d4dcbb..7ce65ec 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -214,7 +214,8 @@ func TestCurrentDutyLocalMidnight(t *testing.T) { } // TestKtoSprzataReply checks the /kto_sprzata handler reads the cached rota and -// replies with this week's duty rendered by formatDuty. +// replies with this week's duty. Only the data (the names) is asserted, not the +// exact wording, so copy tweaks don't break the test. func TestKtoSprzataReply(t *testing.T) { rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}} svc := testService(t, rota, nil) @@ -224,14 +225,13 @@ func TestKtoSprzataReply(t *testing.T) { h.ktoSprzata(context.Background(), b, updateMsg(123, "/kto_sprzata")) got := sent() - want := "Dyżur w tym tygodniu (od 2026-06-15): Ala i Bartek" - if len(got) != 1 || got[0] != want { - t.Fatalf("reply = %v, want [%q]", got, want) + if len(got) != 1 || !strings.Contains(got[0], "Ala") || !strings.Contains(got[0], "Bartek") { + t.Fatalf("reply = %v, want one message naming Ala and Bartek", got) } } -// TestSyncReplySuccess checks /sync triggers a fetch and reports the resulting -// week/person counts. +// TestSyncReplySuccess checks /sync triggers a fetch, persists the result, and +// replies once. func TestSyncReplySuccess(t *testing.T) { fetched := Rota{ Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}, @@ -244,15 +244,17 @@ func TestSyncReplySuccess(t *testing.T) { h.sync(context.Background(), b, updateMsg(123, "/sync")) - got := sent() - want := "Zsynchronizowano: 1 tygodni, 3 osób." - if len(got) != 1 || got[0] != want { - t.Fatalf("reply = %v, want [%q]", got, want) + if got := sent(); len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) + } + rota, _ := svc.Store.Snapshot() + if len(rota.Weeks) != 1 { + t.Errorf("store weeks = %d, want 1 (sync should persist the fetch)", len(rota.Weeks)) } } -// TestSyncReplyThrottled checks a /sync inside the cooldown window is rejected -// with the remaining-wait message and does not trigger a fetch. +// TestSyncReplyThrottled checks a /sync inside the cooldown window does not +// trigger a fetch but still answers (so the user isn't left hanging). func TestSyncReplyThrottled(t *testing.T) { now := fixedNow()() fetch := func(context.Context) (Rota, error) { @@ -266,16 +268,14 @@ func TestSyncReplyThrottled(t *testing.T) { h.sync(context.Background(), b, updateMsg(123, "/sync")) - got := sent() - want := "Synchronizowano niedawno. Spróbuj ponownie za 31s." - if len(got) != 1 || got[0] != want { - t.Fatalf("reply = %v, want [%q]", got, want) + if got := sent(); len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) } } -// TestSyncReplyFailure checks a failed fetch yields the generic failure message -// (internal error details are logged, not shown). The slog.Error this emits is -// expected, not a harness fault. +// TestSyncReplyFailure checks a failed fetch still yields exactly one reply, +// and that internal error details are not leaked into it (they are logged +// instead — the slog.Error this emits is expected, not a harness fault). func TestSyncReplyFailure(t *testing.T) { fetch := func(context.Context) (Rota, error) { return Rota{}, context.DeadlineExceeded @@ -287,15 +287,18 @@ func TestSyncReplyFailure(t *testing.T) { h.sync(context.Background(), b, updateMsg(123, "/sync")) got := sent() - want := "Synchronizacja nie powiodła się. Spróbuj ponownie później." - if len(got) != 1 || got[0] != want { - t.Fatalf("reply = %v, want [%q]", got, want) + if len(got) != 1 { + t.Fatalf("replies = %v, want exactly one", got) + } + if strings.Contains(got[0], context.DeadlineExceeded.Error()) { + t.Errorf("reply %q leaks the internal error", got[0]) } } -// TestUnknownReply checks the fallback answers an unrecognized /command (a -// message carrying a bot_command entity that no matcher claimed). -func TestUnknownReply(t *testing.T) { +// TestUnknownCommandIgnored checks the fallback stays silent on an +// unrecognized /command (a bot_command entity no matcher claimed) — the bot +// answers only its own predefined commands and ignores everything else. +func TestUnknownCommandIgnored(t *testing.T) { svc := testService(t, Rota{}, nil) b, sent := newTestBot(t) h := &botHandlers{svc: svc, now: fixedNow()} @@ -307,16 +310,14 @@ func TestUnknownReply(t *testing.T) { }} h.unknown(context.Background(), b, cmd) - got := sent() - want := "Nieznana komenda. Użyj /kto_sprzata lub /sync." - if len(got) != 1 || got[0] != want { - t.Fatalf("reply = %v, want [%q]", got, want) + if got := sent(); len(got) != 0 { + t.Fatalf("unknown command should not reply, got %v", got) } } // TestUnknownPlainTextIgnored checks ordinary chatter (no bot_command entity) -// gets no reply, so the bot doesn't spam "Nieznana komenda" on every group -// message, service notice, or reply to its own messages. +// gets no reply — the bot stays silent on group messages, service notices, and +// replies to its own messages. func TestUnknownPlainTextIgnored(t *testing.T) { svc := testService(t, Rota{}, nil) b, sent := newTestBot(t) @@ -381,45 +382,3 @@ func TestPublishCommands(t *testing.T) { t.Fatalf("PublishCommands: %v", err) } } - -func TestFormatDuty(t *testing.T) { - tests := []struct { - name string - week DutyWeek - ok bool - want string - }{ - { - "both people", - week(t, "2026-06-15", "Ala", "Bartek"), true, - "Dyżur w tym tygodniu (od 2026-06-15): Ala i Bartek", - }, - { - "only person1", - week(t, "2026-06-15", "Ala", ""), true, - "Dyżur w tym tygodniu (od 2026-06-15): Ala", - }, - { - "only person2", - week(t, "2026-06-15", "", "Bartek"), true, - "Dyżur w tym tygodniu (od 2026-06-15): Bartek", - }, - { - "nobody assigned", - week(t, "2026-06-15", "", ""), true, - "Dyżur w tym tygodniu (od 2026-06-15): brak przypisania", - }, - { - "not found", - DutyWeek{}, false, - "Nie znalazłem dyżuru na ten tydzień.", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := formatDuty(tc.week, tc.ok); got != tc.want { - t.Errorf("formatDuty = %q, want %q", got, tc.want) - } - }) - } -}