feat(blacklist): show the changed list after every add and remove

/blacklist_add, /blacklist_del, /whitelist_add and /whitelist_del now answer
with the current contents of the list they touched, so the sender sees the
result without following up with /blacklist_rules.

All four outcomes end the same way, including the two that change nothing:
text already present, and text that was not there to remove. Those are
exactly when someone wants to see what the list holds, and "every add and
remove shows the list" is a simpler rule than one conditional on whether a
write landed.

The confirmation line shares the listing's byte budget, so a long list
cannot push the combined reply past Telegram's message limit.
This commit is contained in:
tiennm99 committed 2026-09-15 13:48:34 +07:00
1 parent e5125fcd93
commit c47bcf2c7f
4 files changed
+180 -5

No files matched your search

+33 -4
View File
@@ -147,6 +147,33 @@ func (s *state) get(ctx context.Context, key string) (Entry, bool, error) {
return entry, true, nil
}
// replyWithList answers an add or a remove with its confirmation followed by
// the current contents of the list, so the sender sees the result of what they
// just did without running /blacklist_rules.
//
// Every outcome of those four commands ends here, including the two that change
// nothing: "already present" and "not there" are exactly when someone wants to
// see what the list actually holds, and "every add and remove shows the list"
// is a simpler rule to rely on than one conditional on whether a write landed.
//
// The confirmation goes into the builder first, so renderSection's byte budget
// already accounts for it and the combined message still fits one reply.
//
// A failed read here is logged but not reported: the change itself succeeded,
// and answering a completed write with a generic failure would be a lie.
func (s *state) replyWithList(ctx context.Context, b *bot.Bot, msg *models.Message, list, prefix, confirmation string) error {
var sb strings.Builder
sb.WriteString(confirmation)
docs, err := s.store.Scan(ctx, prefix)
if err != nil {
log.Error("blacklist_mutation_scan", "list", list, "err", err)
return chathelper.ReplyHTML(ctx, b, msg, sb.String())
}
renderSection(&sb, list, prefix, docs)
return chathelper.ReplyHTML(ctx, b, msg, sb.String())
}
// handleAdd stores text in one of the thread's two lists.
func (s *state) handleAdd(list string) modules.CommandHandler {
command := listName(list) + "_add"
@@ -165,6 +192,7 @@ func (s *state) handleAdd(list string) modules.CommandHandler {
}
chatID, threadID := threadOf(msg)
prefix := scopePrefix(chatID, threadID, list)
key := entryKey(chatID, threadID, list, normText)
// Read before writing purely to word the reply. The write is
@@ -176,7 +204,7 @@ func (s *state) handleAdd(list string) modules.CommandHandler {
return chathelper.Reply(ctx, b, msg, genericFailure)
}
if found {
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
return s.replyWithList(ctx, b, msg, list, prefix, fmt.Sprintf(
"<code>%s</code> is already in this topic's %s.",
html.EscapeString(existing.Text), listName(list)))
}
@@ -189,7 +217,7 @@ func (s *state) handleAdd(list string) modules.CommandHandler {
log.Error("blacklist_add", "list", list, "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
return s.replyWithList(ctx, b, msg, list, prefix, fmt.Sprintf(
"Added <code>%s</code> to this topic's %s.",
html.EscapeString(raw), listName(list)))
}
@@ -217,6 +245,7 @@ func (s *state) handleDel(list string) modules.CommandHandler {
}
chatID, threadID := threadOf(msg)
prefix := scopePrefix(chatID, threadID, list)
key := entryKey(chatID, threadID, list, normText)
// Read first so absent text is reported as such. Delete on a missing
@@ -227,7 +256,7 @@ func (s *state) handleDel(list string) modules.CommandHandler {
log.Error("blacklist_del_lookup", "list", list, "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
} else if !found {
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
return s.replyWithList(ctx, b, msg, list, prefix, fmt.Sprintf(
"<code>%s</code> is not in this topic's %s.",
html.EscapeString(raw), listName(list)))
}
@@ -236,7 +265,7 @@ func (s *state) handleDel(list string) modules.CommandHandler {
log.Error("blacklist_del", "list", list, "err", err)
return chathelper.Reply(ctx, b, msg, genericFailure)
}
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
return s.replyWithList(ctx, b, msg, list, prefix, fmt.Sprintf(
"Removed <code>%s</code> from this topic's %s.",
html.EscapeString(raw), listName(list)))
}
+72 -1
View File
@@ -2,6 +2,7 @@ package blacklist_test
import (
"context"
"fmt"
"strings"
"testing"
@@ -210,7 +211,7 @@ func TestRules_TrimsToOneMessage(t *testing.T) {
const entries = 400
for i := range entries {
send(t, rb, testutil.NewPrivateMessage(7,
"/blacklist_add "+strings.Repeat("x", 30)+string(rune('a'+i%26))+strings.Repeat("y", i%7)))
fmt.Sprintf("/blacklist_add %s%03d", strings.Repeat("x", 30), i)))
}
got := send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_rules"))
@@ -388,3 +389,73 @@ func TestReplyChainThreadIsNotATopic(t *testing.T) {
t.Fatalf("rules reply = %q; want the entry listed", got)
}
}
// A mutation answers with the current contents of the list it changed, so the
// sender never has to follow it with /blacklist_rules.
func TestAddAndDel_ShowTheChangedList(t *testing.T) {
rb := installBlacklist(t)
first := send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_add cat"))
if !strings.Contains(first, "<b>Blacklist</b> (1)") || !strings.Contains(first, "<code>cat</code>") {
t.Fatalf("add reply = %q; want the list appended", first)
}
second := send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_add dog"))
if !strings.Contains(second, "<b>Blacklist</b> (2)") {
t.Fatalf("add reply = %q; want both entries counted", second)
}
if !strings.Contains(second, "<code>cat</code>") || !strings.Contains(second, "<code>dog</code>") {
t.Fatalf("add reply = %q; want the whole list, not just the new entry", second)
}
removed := send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_del cat"))
if !strings.Contains(removed, "<b>Blacklist</b> (1)") {
t.Fatalf("del reply = %q; want the list after removal", removed)
}
// Only the listing may be checked for absence: the confirmation line above
// it echoes the removed text by design.
_, listing, _ := strings.Cut(removed, "<b>Blacklist</b>")
if strings.Contains(listing, "<code>cat</code>") {
t.Fatalf("del reply = %q; the removed entry is still listed", removed)
}
if !strings.Contains(listing, "<code>dog</code>") {
t.Fatalf("del reply = %q; want the surviving entry listed", removed)
}
empty := send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_del dog"))
if !strings.Contains(empty, "<b>Blacklist</b> (0)") || !strings.Contains(empty, "nothing yet") {
t.Fatalf("del reply = %q; want an empty list shown", empty)
}
}
// Each list answers with itself: a whitelist change shows the whitelist, not
// the blacklist.
func TestWhitelistMutation_ShowsOnlyTheWhitelist(t *testing.T) {
rb := installBlacklist(t)
send(t, rb, testutil.NewPrivateMessage(7, "/blacklist_add cat"))
got := send(t, rb, testutil.NewPrivateMessage(7, "/whitelist_add exception"))
if !strings.Contains(got, "<b>Whitelist</b> (1)") {
t.Fatalf("whitelist add reply = %q; want the whitelist", got)
}
if strings.Contains(got, "<b>Blacklist</b>") {
t.Fatalf("whitelist add reply = %q; want only the changed list", got)
}
}
// The confirmation shares the list's byte budget, so a long list cannot push
// the combined reply past Telegram's limit.
func TestAdd_ReplyStaysWithinOneMessage(t *testing.T) {
rb := installBlacklist(t)
var last string
for i := range 400 {
last = send(t, rb, testutil.NewPrivateMessage(7,
fmt.Sprintf("/blacklist_add %s%03d", strings.Repeat("x", 30), i)))
}
if n := len([]rune(last)); n > 4096 {
t.Fatalf("add reply is %d characters, over Telegram's limit", n)
}
if !strings.Contains(last, "more.") {
t.Fatalf("add reply = %q; want a trim notice", last)
}
}