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.
This commit is contained in:
tiennm99 committed 2026-09-15 13:37:03 +07:00
1 parent d2272bd84d
commit e5125fcd93
20 files changed
+2161 -1

No files matched your search

+6
View File
@@ -75,6 +75,12 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"addsticker": "[emoji...]",
"alias": "<name>",
"aliases": "",
"blacklist_add": "[text...]",
"blacklist_del": "<text...>",
"blacklist_rules": "",
"blacklist_check": "<text...>",
"whitelist_add": "[text...]",
"whitelist_del": "<text...>",
"unalias": "<name>",
"insert": "<name>",
"stats": "[users | user <username> | cmd <command_name>]",
+2
View File
@@ -20,6 +20,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/alias"
"github.com/tiennm99/miti99bot/internal/modules/amlich"
"github.com/tiennm99/miti99bot/internal/modules/blacklist"
"github.com/tiennm99/miti99bot/internal/modules/coin"
"github.com/tiennm99/miti99bot/internal/modules/gold"
"github.com/tiennm99/miti99bot/internal/modules/lol"
@@ -95,6 +96,7 @@ func factories() map[string]modules.Factory {
"stats": stats.New,
sticker.CollectionName: sticker.New,
"alias": alias.New,
"blacklist": blacklist.New,
}
}
+31
View File
@@ -128,3 +128,34 @@ func TestFactoriesIncludesExpectedModules(t *testing.T) {
}
}
}
func TestFactoriesRegistersBlacklistCommands(t *testing.T) {
catalog := factories()
if catalog["blacklist"] == nil {
t.Fatal("factories missing blacklist")
}
reg, err := modules.Build([]string{"blacklist"}, catalog, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build blacklist: %v", err)
}
for _, name := range []string{
"blacklist_add", "blacklist_del", "blacklist_rules", "blacklist_check",
"whitelist_add", "whitelist_del",
} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if got := len(reg.AllCommands); got != 6 {
t.Fatalf("blacklist registered %d commands, want 6", got)
}
}
// An empty MODULES loads every module, so a command name that collides with an
// existing module surfaces here as a test failure rather than as a startup
// crash on deploy.
func TestFactoriesBuildWholeCatalog(t *testing.T) {
if _, err := modules.Build(nil, factories(), storage.NewMemoryProvider(), modules.BuildOptions{}); err != nil {
t.Fatalf("Build whole catalog: %v", err)
}
}