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

64 lines
2.5 KiB
Go

package blacklist
import (
"strconv"
"strings"
)
// The two list tags. They are single letters because they sit inside every
// storage key, and because the ':' after them is what ends the key prefix.
const (
listBlack = "b"
listWhite = "w"
)
// maxEntryBytes caps one entry, measured on the normalized text before key
// encoding.
//
// Storage keys are limited to 1500 bytes (internal/storage/keys.go). Worst-case
// encoding triples an entry to 600 bytes, which with a prefix well under 30
// bytes stays far inside that. Two hundred bytes is roughly 65 Vietnamese
// characters — ample for a phrase, and a deliberate refusal to let a pasted
// essay become a key.
const maxEntryBytes = 200
// scopePrefix is the key prefix for one list of one thread.
//
// A thread is (Chat.ID, MessageThreadID), the same pair the lol module uses for
// subscriptions. A zero thread id means a non-forum group, a forum's General
// topic, or a DM — a real scope, not a missing value.
//
// Both numbers are decimal and the list tag is a single letter, so the third
// ':' always ends the prefix even though entry text may contain ':' of its own.
func scopePrefix(chatID int64, threadID int, list string) string {
return strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(threadID) + ":" + list + ":"
}
// entryKey names one entry. normText must already have been through Normalize.
func entryKey(chatID int64, threadID int, list, normText string) string {
return scopePrefix(chatID, threadID, list) + encodeKeyText(normText)
}
// encodeKeyText escapes the one character that cannot appear literally in a
// storage key.
//
// '%' is escaped first so the escape marker itself stays unambiguous: reversing
// the order would make an entry containing the literal text "%2F" decode back
// as "/". Because every literal '%' becomes "%25", every '%' in the result
// starts an escape, so decoding can never match one that spans two of them.
//
// The other key rules need no work here: "." , ".." and the __namespace__
// pattern are all unreachable behind the numeric scope prefix.
func encodeKeyText(s string) string {
s = strings.ReplaceAll(s, "%", "%25")
return strings.ReplaceAll(s, "/", "%2F")
}
// decodeKeyText reverses encodeKeyText. The order mirrors it: "%2F" can only
// have come from a real '/', so it is consumed before "%25" reconstitutes the
// '%' characters that could otherwise form a spurious escape.
func decodeKeyText(s string) string {
s = strings.ReplaceAll(s, "%2F", "/")
return strings.ReplaceAll(s, "%25", "%")
}