mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 06:18:06 +00:00
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.
111 lines
3.3 KiB
Go
111 lines
3.3 KiB
Go
package blacklist
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// Verdict is the outcome of checking one text against one thread's rules.
|
|
//
|
|
// Entry is set whenever some blacklist entry occurred in the text, blocked or
|
|
// not: a rescued match is worth naming, because it is the only way a user can
|
|
// confirm an exception they added is doing anything.
|
|
type Verdict struct {
|
|
Blocked bool
|
|
Entry string // the blacklist entry that decided the verdict; "" when none matched
|
|
RescuedBy string // the whitelist entry covering Entry; set only when !Blocked && Entry != ""
|
|
}
|
|
|
|
// span is a half-open byte range within the text being checked.
|
|
type span struct{ start, end int }
|
|
|
|
// allowSpan is a whitelist occurrence, carrying the entry that produced it so a
|
|
// rescue can be reported by name.
|
|
type allowSpan struct {
|
|
span
|
|
entry string
|
|
}
|
|
|
|
// Check judges normText against two lists of normalized entries.
|
|
//
|
|
// The rule is span containment, not mere presence: a whitelist entry rescues a
|
|
// blacklist match only when it occurs in the text at a range that contains that
|
|
// match. The tempting shortcut — allow the text if any whitelist entry appears
|
|
// anywhere in it — handles "assassin" correctly and then lets "I met an
|
|
// assassin, dumbass" through, because the rescuing span never reaches the
|
|
// second match. A whitelist entry that merely overlaps a match without
|
|
// containing it does not rescue it.
|
|
//
|
|
// Both slices are copied and sorted before scanning. Callers read them from
|
|
// DocStore.List, which guarantees no ordering, so without this the same text
|
|
// could be reported against a different entry on each invocation.
|
|
func Check(normText string, blacklist, whitelist []string) Verdict {
|
|
if normText == "" {
|
|
return Verdict{}
|
|
}
|
|
|
|
allowed := allowSpans(normText, whitelist)
|
|
|
|
// The first rescued match, kept in case no unrescued one is ever found.
|
|
var rescued Verdict
|
|
|
|
for _, entry := range slices.Sorted(slices.Values(blacklist)) {
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
for _, s := range occurrences(normText, entry) {
|
|
by, ok := coveredBy(s, allowed)
|
|
if !ok {
|
|
return Verdict{Blocked: true, Entry: entry}
|
|
}
|
|
if rescued.Entry == "" {
|
|
rescued = Verdict{Entry: entry, RescuedBy: by}
|
|
}
|
|
}
|
|
}
|
|
return rescued
|
|
}
|
|
|
|
// allowSpans collects every occurrence of every whitelist entry.
|
|
func allowSpans(text string, whitelist []string) []allowSpan {
|
|
var out []allowSpan
|
|
for _, entry := range slices.Sorted(slices.Values(whitelist)) {
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
for _, s := range occurrences(text, entry) {
|
|
out = append(out, allowSpan{span: s, entry: entry})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// occurrences returns every range at which sub appears in s.
|
|
//
|
|
// The scan advances one byte past each hit rather than past the whole match, so
|
|
// overlapping occurrences are all reported — "aa" occurs twice in "aaa", and a
|
|
// containment test that saw only the first would be wrong.
|
|
func occurrences(s, sub string) []span {
|
|
var out []span
|
|
for off := 0; off < len(s); {
|
|
i := strings.Index(s[off:], sub)
|
|
if i < 0 {
|
|
break
|
|
}
|
|
start := off + i
|
|
out = append(out, span{start: start, end: start + len(sub)})
|
|
off = start + 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
// coveredBy reports the whitelist entry whose span contains s, if any.
|
|
func coveredBy(s span, allowed []allowSpan) (string, bool) {
|
|
for _, a := range allowed {
|
|
if a.start <= s.start && s.end <= a.end {
|
|
return a.entry, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|