Files
tiennm99 e5125fcd93 feat(blacklist): add per-topic text deny-list with whitelist exceptions
Six public commands let any member of a chat curate two lists of text and
ask whether a given text is blocked: /blacklist_add, /blacklist_del,
/whitelist_add, /whitelist_del, /blacklist_rules and /blacklist_check.

The module is passive. It never reads ordinary chat messages and never
deletes, warns or restricts anyone; the lists stay inert until
/blacklist_check asks about a specific text.

Scope is one forum topic, keyed (Chat.ID, MessageThreadID) and gated on
IsTopicMessage so a reply chain in a plain supergroup does not become its
own unreachable scope. A plain group, a DM and a forum's General topic all
resolve to one chat-wide list.

Matching is substring, after NFKC composition, case folding and whitespace
collapse. Diacritics stay significant, so ma, má and mà are three entries.
A whitelist entry rescues a blacklist match only when it spans that match,
which is what keeps "I met an assassin, dumbass" blocked.

Entry text is percent-encoded before it becomes a storage key, since keys
forbid '/' and cap at 1500 bytes, and is capped at 200 bytes before and
after normalization because NFKC can expand as well as contract.

/blacklist_rules reads each list with Scan, so listing costs one round trip
per list rather than a Get per entry.
2026-09-15 13:37:03 +07:00

63 lines
1.8 KiB
Go

package blacklist
import (
"strings"
"testing"
)
func TestScopePrefix_EndsAfterThreeColons(t *testing.T) {
got := scopePrefix(-1001234567890, 0, listBlack)
if want := "-1001234567890:0:b:"; got != want {
t.Fatalf("scopePrefix = %q, want %q", got, want)
}
if strings.Count(got, ":") != 3 {
t.Fatalf("prefix %q does not carry exactly three colons", got)
}
}
// Two threads of one chat must produce prefixes where neither is a prefix of
// the other, or a List for one thread would return the other's entries.
func TestScopePrefix_ThreadsDoNotNest(t *testing.T) {
a := scopePrefix(-100, 1, listBlack)
b := scopePrefix(-100, 12, listBlack)
if strings.HasPrefix(b, a) || strings.HasPrefix(a, b) {
t.Fatalf("thread prefixes nest: %q and %q", a, b)
}
}
func TestScopePrefix_ListsDoNotCollide(t *testing.T) {
if scopePrefix(-100, 0, listBlack) == scopePrefix(-100, 0, listWhite) {
t.Fatal("blacklist and whitelist share a prefix")
}
}
func TestEncodeKeyText_RoundTrips(t *testing.T) {
for _, in := range []string{
"plain",
"a/b",
"100%",
"%2F", // the literal text that a naive decoder turns into "/"
"%25", // likewise for "%"
"a:b", // the key delimiter, which needs no escaping
"a\nb",
"%2F/%25%", // every hazard at once
} {
enc := encodeKeyText(in)
if strings.Contains(enc, "/") {
t.Fatalf("encodeKeyText(%q) = %q still contains '/'", in, enc)
}
if got := decodeKeyText(enc); got != in {
t.Fatalf("round trip of %q gave %q (encoded %q)", in, got, enc)
}
}
}
func TestEntryKey_StaysWithinStorageLimits(t *testing.T) {
// Worst case: an entry at the cap made entirely of characters that encode
// to three bytes each.
key := entryKey(-1001234567890, 999999, listBlack, strings.Repeat("/", maxEntryBytes))
if len(key) > 1500 {
t.Fatalf("worst-case key is %d bytes, over the 1500-byte storage limit", len(key))
}
}