Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
Deploy PROD / deploy (push) Successful in 33s
Deploy PROD / deploy (push) Successful in 33s
Wiadomosc czatu i pytanie uczestnika przechodza przez slownik przed publikacja. Trafienie wstrzymuje je (status NEW, moderation_hit), widzi je tylko panel prowadzenia z przyciskami Opublikuj / Odrzuc, a autor dostaje informacje, ze wiadomosc czeka. Wiadomosci prowadzacych nie sa filtrowane. Normalizacja rozpoznaje ogonki, wielkie litery, cyfry zamiast liter, powtorzenia i rozstrzelenia. Slownik startowy w V31: wulgaryzmy, obelgi, tresci zwiazane z pedofilia. WordFilterTest czyta slownik z migracji i sprawdza obie strony, takze slownictwo wodociagowe. Ekran "Moderacja wyrazow" w menu: lista z wyszukiwaniem i kategoriami, dodawanie, edycja, wylaczanie, usuwanie z potwierdzeniem, sprawdzanie tekstu. Dostep ADMIN i MODERATOR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,14 @@ public class Interaction {
|
||||
@JoinColumn(name = "source_banner_id")
|
||||
private Banner sourceBanner;
|
||||
|
||||
/**
|
||||
* Wpis slownika moderacji, ktory wstrzymal wiadomosc. Null, gdy przeszla
|
||||
* bez trafienia. Tekst, nie klucz obcy: wpis moze zniknac ze slownika,
|
||||
* a panel ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||
*/
|
||||
@Column(name = "moderation_hit", length = 100)
|
||||
private String moderationHit;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
/** Kategoria wpisu w slowniku moderacji. Sluzy do opisu w panelu i filtrowania listy. */
|
||||
public enum ModerationCategory {
|
||||
/** Wulgaryzm. */
|
||||
VULGAR,
|
||||
/** Obelga, takze na tle pochodzenia czy orientacji. */
|
||||
INSULT,
|
||||
/** Tresci zwiazane z pedofilia i wykorzystywaniem dzieci. */
|
||||
CHILD_ABUSE
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
/**
|
||||
* Sposob dopasowania wpisu slownika do slowa w wiadomosci.
|
||||
*
|
||||
* Wpis z odstepem jest fraza i szukany jest w calym tekscie, niezaleznie od
|
||||
* wybranego trybu.
|
||||
*/
|
||||
public enum ModerationMatchMode {
|
||||
/** Cale slowo: "dupa" trafia w "dupa", ale nie w "duplikat". */
|
||||
WORD,
|
||||
/** Poczatek slowa, czyli wszystkie odmiany: "debil" trafia w "debilu". */
|
||||
PREFIX,
|
||||
/** Fragment slowa, takze w zlozeniach: "jeb" trafia w "zajebisty". */
|
||||
CONTAINS
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.softspm.webinar.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* Wpis slownika moderacji czatu i pytan.
|
||||
*
|
||||
* `phrase` jest w zwyklej pisowni, tak jak wpisal ja administrator. Postac
|
||||
* znormalizowana liczy `WordFilter` przy wczytaniu slownika, a nie baza: ta
|
||||
* sama funkcja normalizuje wiadomosc, wiec obie strony nie moga sie rozjechac.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "moderation_words")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ModerationWord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "phrase", nullable = false, length = 100)
|
||||
private String phrase;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "category", nullable = false, length = 20)
|
||||
private ModerationCategory category;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "match_mode", nullable = false, length = 20)
|
||||
private ModerationMatchMode matchMode;
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
if (this.createdAt == null) {
|
||||
this.createdAt = now;
|
||||
}
|
||||
if (this.updatedAt == null) {
|
||||
this.updatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -108,12 +108,17 @@ public class InteractionAdminController {
|
||||
}
|
||||
|
||||
@PostMapping("/{interactionId}/approve")
|
||||
@Operation(summary = "Zatwierdzenie pytania, po ktorym staje sie publiczne")
|
||||
@Operation(summary = "Zatwierdzenie pytania albo wstrzymanej wiadomosci, po ktorym staje sie publiczne")
|
||||
public InteractionView approve(@PathVariable Long id, @PathVariable Long interactionId) {
|
||||
interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
||||
Interaction interaction = interactions.setStatus(interactionId, InteractionStatus.APPROVED);
|
||||
InteractionView view = interactions.view(interactionId);
|
||||
|
||||
broadcaster.questionPublished(id, view);
|
||||
// wiadomosc wstrzymana przez slownik idzie na czat, pytanie do kolejki
|
||||
if (interaction.getType() == InteractionType.CHAT) {
|
||||
broadcaster.chatPosted(id, view);
|
||||
} else {
|
||||
broadcaster.questionPublished(id, view);
|
||||
}
|
||||
broadcaster.moderation(id, "APPROVED", interactions.moderatorView(interactionId));
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.softspm.webinar.interaction;
|
||||
|
||||
import com.softspm.webinar.moderation.ModerationWordService;
|
||||
import com.softspm.webinar.moderation.WordFilter;
|
||||
import com.softspm.webinar.content.PlainTextSanitizer;
|
||||
import com.softspm.webinar.domain.ChatMode;
|
||||
import com.softspm.webinar.domain.Interaction;
|
||||
@@ -48,6 +50,7 @@ public class InteractionService {
|
||||
private final UserRepository userRepository;
|
||||
private final PlainTextSanitizer sanitizer;
|
||||
private final SettingsService settings;
|
||||
private final ModerationWordService moderation;
|
||||
|
||||
public InteractionService(InteractionRepository interactionRepository,
|
||||
InteractionVoteRepository voteRepository,
|
||||
@@ -56,7 +59,9 @@ public class InteractionService {
|
||||
WebinarSpeakerRepository speakerRepository,
|
||||
UserRepository userRepository,
|
||||
PlainTextSanitizer sanitizer,
|
||||
SettingsService settings) {
|
||||
SettingsService settings,
|
||||
ModerationWordService moderation) {
|
||||
this.moderation = moderation;
|
||||
this.interactionRepository = interactionRepository;
|
||||
this.voteRepository = voteRepository;
|
||||
this.webinarRepository = webinarRepository;
|
||||
@@ -70,8 +75,10 @@ public class InteractionService {
|
||||
/**
|
||||
* Nowa wiadomosc na czacie.
|
||||
*
|
||||
* Czat nie ma moderacji wstepnej, wiec wiadomosc od razu dostaje status
|
||||
* APPROVED. Moderator moze ja pozniej usunac, co zmienia status na REJECTED.
|
||||
* Wiadomosc uczestnika przechodzi przez slownik moderacji PRZED publikacja.
|
||||
* Bez trafienia dostaje od razu APPROVED; z trafieniem czeka jako NEW,
|
||||
* widoczna wylacznie w panelu prowadzenia, az prowadzacy ja opublikuje albo
|
||||
* odrzuci. Wiadomosci prowadzacych nie sa filtrowane.
|
||||
*/
|
||||
@Transactional
|
||||
public Interaction postChat(InteractionPrincipal author, String rawContent) {
|
||||
@@ -107,7 +114,13 @@ public class InteractionService {
|
||||
}
|
||||
|
||||
Interaction chat = newInteraction(webinar, author, InteractionType.CHAT, content);
|
||||
chat.setStatus(InteractionStatus.APPROVED);
|
||||
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||
if (hit != null) {
|
||||
chat.setStatus(InteractionStatus.NEW);
|
||||
chat.setModerationHit(hit.phrase());
|
||||
} else {
|
||||
chat.setStatus(InteractionStatus.APPROVED);
|
||||
}
|
||||
return interactionRepository.save(chat);
|
||||
}
|
||||
|
||||
@@ -128,7 +141,12 @@ public class InteractionService {
|
||||
}
|
||||
|
||||
Interaction question = newInteraction(webinar, author, InteractionType.QUESTION, content);
|
||||
question.setStatus(webinar.isQuestionsRequireApproval()
|
||||
// trafienie w slowniku wstrzymuje pytanie takze przy wylaczonej moderacji
|
||||
WordFilter.Hit hit = author.speaker() ? null : moderation.check(content);
|
||||
if (hit != null) {
|
||||
question.setModerationHit(hit.phrase());
|
||||
}
|
||||
question.setStatus(webinar.isQuestionsRequireApproval() || hit != null
|
||||
? InteractionStatus.NEW
|
||||
: InteractionStatus.APPROVED);
|
||||
return interactionRepository.save(question);
|
||||
@@ -211,7 +229,8 @@ public class InteractionService {
|
||||
public List<Interaction> fullChatHistory(Long webinarId) {
|
||||
return interactionRepository.findByWebinarIdAndTypeAndStatusInOrderByCreatedAtAsc(
|
||||
webinarId, InteractionType.CHAT,
|
||||
List.of(InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
||||
// NEW to wiadomosci wstrzymane przez slownik, czekajace na decyzje
|
||||
List.of(InteractionStatus.NEW, InteractionStatus.APPROVED, InteractionStatus.REJECTED));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,6 +60,15 @@ public class InteractionStompController {
|
||||
public void chat(@Payload MessagePayload payload, Principal principal) {
|
||||
InteractionPrincipal author = require(principal);
|
||||
Interaction chat = interactions.postChat(author, payload.content());
|
||||
|
||||
if (chat.getStatus() == InteractionStatus.NEW) {
|
||||
// wstrzymana przez slownik: widzi ja tylko prowadzacy, a autor
|
||||
// dostaje informacje, ze wiadomosc nie zniknela, tylko czeka
|
||||
broadcaster.moderation(author.webinarId(), "CHAT_HELD",
|
||||
interactions.moderatorView(chat.getId()));
|
||||
throw new ValidationException("Twoja wiadomość czeka na akceptację prowadzącego.");
|
||||
}
|
||||
|
||||
InteractionView view = interactions.view(chat.getId());
|
||||
broadcaster.chatPosted(author.webinarId(), view);
|
||||
// panel dostaje widok z identyfikatorem autora, zeby dalo sie go wyciszyc
|
||||
|
||||
@@ -35,7 +35,9 @@ public record ModeratorInteractionView(
|
||||
Long authorRegistrationId,
|
||||
boolean authorChatMuted,
|
||||
boolean authorRoomBanned,
|
||||
OffsetDateTime createdAt) {
|
||||
OffsetDateTime createdAt,
|
||||
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
|
||||
String moderationHit) {
|
||||
|
||||
/**
|
||||
* @param muted czy autor jest wyciszony na czacie
|
||||
@@ -59,6 +61,7 @@ public record ModeratorInteractionView(
|
||||
regId,
|
||||
muted,
|
||||
banned,
|
||||
interaction.getCreatedAt());
|
||||
interaction.getCreatedAt(),
|
||||
interaction.getModerationHit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.softspm.webinar.moderation;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Slownik moderacji w panelu.
|
||||
*
|
||||
* Pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy, ktory w trakcie
|
||||
* spotkania zauwazy nowe okreslenie, ma je dopisac od razu, bez szukania
|
||||
* administratora.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/moderation-words")
|
||||
@Tag(name = "Moderacja wyrazow", description = "Slownik slow wstrzymujacych wiadomosci do akceptacji")
|
||||
public class ModerationWordController {
|
||||
|
||||
private final ModerationWordService service;
|
||||
|
||||
public ModerationWordController(ModerationWordService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista wpisow, stronicowana, z wyszukiwaniem i filtrem kategorii")
|
||||
public Page<ModerationWordService.WordView> list(@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) ModerationCategory category,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return service.list(q, category, page, Math.min(size, 100));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Dodanie wpisu")
|
||||
public ModerationWordService.WordView create(@Valid @RequestBody WordBody body) {
|
||||
return service.create(body.toRequest());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Zmiana wpisu")
|
||||
public ModerationWordService.WordView update(@PathVariable Long id, @Valid @RequestBody WordBody body) {
|
||||
return service.update(id, body.toRequest());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Usuniecie wpisu")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
}
|
||||
|
||||
@PostMapping("/check")
|
||||
@Operation(summary = "Sprawdzenie, czy tekst zostalby wstrzymany")
|
||||
public ModerationWordService.CheckResult check(@Valid @RequestBody CheckBody body) {
|
||||
return service.test(body.text());
|
||||
}
|
||||
|
||||
public record WordBody(
|
||||
@NotBlank(message = "Wpisz słowo albo frazę")
|
||||
@Size(max = 100, message = "Wpis może mieć najwyżej 100 znaków")
|
||||
String phrase,
|
||||
@NotNull(message = "Wybierz kategorię")
|
||||
ModerationCategory category,
|
||||
@NotNull(message = "Wybierz sposób dopasowania")
|
||||
ModerationMatchMode matchMode,
|
||||
Boolean active) {
|
||||
|
||||
ModerationWordService.WordRequest toRequest() {
|
||||
return new ModerationWordService.WordRequest(phrase, category, matchMode, active);
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckBody(
|
||||
@Size(max = 2000, message = "Tekst do sprawdzenia może mieć najwyżej 2000 znaków")
|
||||
String text) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.softspm.webinar.moderation;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||
import com.softspm.webinar.domain.ModerationWord;
|
||||
import com.softspm.webinar.repository.ModerationWordRepository;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Slownik moderacji i sprawdzanie tresci przed publikacja.
|
||||
*
|
||||
* SLOWNIK W PAMIECI
|
||||
* ---------------------------------------------------------------------------
|
||||
* Filtr sprawdza kazda wiadomosc na czacie, wiec nie moze pytac bazy za kazdym
|
||||
* razem: przy dwustu piszacych osobach to setki zapytan na minute o dane,
|
||||
* ktore zmieniaja sie raz na tydzien. Slownik jest wczytywany raz, znormalizowany
|
||||
* i trzymany w pamieci, a kazda zmiana w panelu go uniewaznia.
|
||||
*
|
||||
* BLAD FILTRU NIE BLOKUJE CZATU
|
||||
* ---------------------------------------------------------------------------
|
||||
* Gdyby wczytanie slownika sie nie powiodlo, wiadomosc przechodzi bez
|
||||
* sprawdzenia, a blad trafia do logu. Czat, ktory przestaje dzialac przez
|
||||
* awarie filtru, jest gorszy niz czat bez filtru przez chwile.
|
||||
*/
|
||||
@Service
|
||||
public class ModerationWordService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ModerationWordService.class);
|
||||
|
||||
private final ModerationWordRepository repository;
|
||||
|
||||
/** Slownik po normalizacji. Null oznacza "do wczytania przy najblizszym uzyciu". */
|
||||
private volatile List<WordFilter.Entry> entries;
|
||||
|
||||
public ModerationWordService(ModerationWordRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdzenie tresci przed publikacja.
|
||||
*
|
||||
* @return trafienie albo null, gdy tresc moze isc od razu
|
||||
*/
|
||||
public WordFilter.Hit check(String text) {
|
||||
try {
|
||||
return WordFilter.find(text, entries());
|
||||
} catch (Exception e) {
|
||||
log.warn("Moderacja: sprawdzenie tresci nieudane, wiadomosc przechodzi bez filtru: {}",
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<WordFilter.Entry> entries() {
|
||||
List<WordFilter.Entry> current = entries;
|
||||
if (current == null) {
|
||||
current = repository.findByActiveTrue().stream()
|
||||
.map(w -> WordFilter.Entry.of(w.getPhrase(), w.getCategory(), w.getMatchMode()))
|
||||
.toList();
|
||||
entries = current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniewaznienie slownika PO zatwierdzeniu zmiany.
|
||||
*
|
||||
* Uniewaznienie w trakcie transakcji pozwalaloby rownoleglej wiadomosci
|
||||
* wczytac jeszcze stara wersje i zostawic ja w pamieci az do nastepnej
|
||||
* zmiany w panelu, czyli potencjalnie na tygodnie.
|
||||
*/
|
||||
private void invalidate() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
entries = null;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
entries = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<WordView> list(String query, ModerationCategory category, int page, int size) {
|
||||
String q = query == null || query.isBlank() ? null : query.trim();
|
||||
return repository.search(q, category,
|
||||
PageRequest.of(Math.max(0, page), Math.max(1, Math.min(size, 100)),
|
||||
Sort.by("phrase").ascending()))
|
||||
.map(WordView::of);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WordView create(WordRequest request) {
|
||||
ModerationWord word = new ModerationWord();
|
||||
apply(word, request);
|
||||
WordView view = save(word);
|
||||
invalidate();
|
||||
log.info("Moderacja: dodano wpis {}", view.id());
|
||||
return view;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WordView update(Long id, WordRequest request) {
|
||||
ModerationWord word = repository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
|
||||
apply(word, request);
|
||||
WordView view = save(word);
|
||||
invalidate();
|
||||
return view;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
ModerationWord word = repository.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu"));
|
||||
repository.delete(word);
|
||||
invalidate();
|
||||
log.info("Moderacja: usunieto wpis {}", id);
|
||||
}
|
||||
|
||||
/** Podglad dzialania filtru na dowolnym tekscie, dla panelu. */
|
||||
public CheckResult test(String text) {
|
||||
WordFilter.Hit hit = WordFilter.find(text, entries());
|
||||
return hit == null
|
||||
? new CheckResult(false, null, null)
|
||||
: new CheckResult(true, hit.phrase(), hit.category());
|
||||
}
|
||||
|
||||
private void apply(ModerationWord word, WordRequest request) {
|
||||
String phrase = request.phrase() == null ? "" : request.phrase().trim().replaceAll("\\s+", " ");
|
||||
String normalized = WordFilter.normalize(phrase);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new ValidationException("Wpis musi zawierać co najmniej jedną literę.");
|
||||
}
|
||||
if (request.matchMode() == ModerationMatchMode.CONTAINS
|
||||
&& normalized.indexOf(' ') < 0
|
||||
&& normalized.length() < WordFilter.MIN_CONTAINS_LENGTH) {
|
||||
throw new ValidationException("Fragment słowa musi mieć co najmniej "
|
||||
+ WordFilter.MIN_CONTAINS_LENGTH
|
||||
+ " litery. Krótszy zatrzymywałby zwykłe wiadomości.");
|
||||
}
|
||||
|
||||
// ten sam wpis w innej pisowni ("Kurwa" i "kurwa") dzialalby tak samo
|
||||
for (ModerationWord other : repository.findAll()) {
|
||||
if (!other.getId().equals(word.getId())
|
||||
&& WordFilter.normalize(other.getPhrase()).equals(normalized)
|
||||
&& other.getMatchMode() == request.matchMode()) {
|
||||
throw new ValidationException("Ten wpis już jest na liście: „" + other.getPhrase() + "”.");
|
||||
}
|
||||
}
|
||||
|
||||
word.setPhrase(phrase);
|
||||
word.setCategory(request.category());
|
||||
word.setMatchMode(request.matchMode());
|
||||
word.setActive(request.active() == null || request.active());
|
||||
}
|
||||
|
||||
private WordView save(ModerationWord word) {
|
||||
try {
|
||||
return WordView.of(repository.saveAndFlush(word));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw new ValidationException("Ten wpis już jest na liście.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- widoki ---
|
||||
|
||||
public record WordRequest(String phrase, ModerationCategory category,
|
||||
ModerationMatchMode matchMode, Boolean active) {
|
||||
}
|
||||
|
||||
public record WordView(Long id, String phrase, ModerationCategory category,
|
||||
ModerationMatchMode matchMode, boolean active,
|
||||
OffsetDateTime createdAt) {
|
||||
|
||||
static WordView of(ModerationWord w) {
|
||||
return new WordView(w.getId(), w.getPhrase(), w.getCategory(), w.getMatchMode(),
|
||||
w.isActive(), w.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckResult(boolean held, String phrase, ModerationCategory category) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.softspm.webinar.moderation;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Dopasowanie tresci do slownika moderacji.
|
||||
*
|
||||
* Czysta logika, bez bazy i bez Springa: wiadomosc i wpisy slownika przechodza
|
||||
* przez TA SAMA normalizacje, wiec administrator wpisuje slowo w zwyklej
|
||||
* pisowni, a nie we wszystkich wariantach, jakie wymysli uczestnik.
|
||||
*
|
||||
* NORMALIZACJA
|
||||
* ---------------------------------------------------------------------------
|
||||
* - male litery, bez ogonkow ("Kurwą" i "kurwa" to to samo),
|
||||
* - cyfry i znaki udajace litery zamienione na litery ("kurw4", "p1zda"),
|
||||
* - powtorzenia zwiniete ("kuuurwa"); to samo dzieje sie z wpisem slownika,
|
||||
* wiec wpis z podwojna litera nadal trafia,
|
||||
* - znaki rozdzielajace wewnatrz slowa usuniete ("k.u.r.w.a", "ch_uj"),
|
||||
* - pojedyncze litery rozdzielone spacjami sklejone ("k u r w a").
|
||||
*
|
||||
* Kazdy z tych wariantow jest sprawdzany osobno, bo kazdy usuwa cos innego:
|
||||
* sklejanie liter po kropkach zlaczyloby tez dwa zdania bez spacji po kropce.
|
||||
*
|
||||
* CZEGO TO NIE ZALATWI
|
||||
* ---------------------------------------------------------------------------
|
||||
* Obelgi bez slow ze slownika, aluzji ani kontekstu. Dlatego trafienie
|
||||
* wstrzymuje wiadomosc do decyzji prowadzacego, a nie kasuje jej: filtr ma
|
||||
* zatrzymac to, czego nikt nie powinien zobaczyc, zanim ktos to przeczyta,
|
||||
* a nie zastapic moderatora.
|
||||
*/
|
||||
public final class WordFilter {
|
||||
|
||||
/** Minimalna dlugosc wpisu w trybie fragmentu: krotszy trafialby w zwykle slowa. */
|
||||
public static final int MIN_CONTAINS_LENGTH = 3;
|
||||
|
||||
private WordFilter() {
|
||||
}
|
||||
|
||||
/** Wpis slownika po normalizacji. */
|
||||
public record Entry(String phrase, ModerationCategory category, ModerationMatchMode mode,
|
||||
String normalized) {
|
||||
|
||||
public static Entry of(String phrase, ModerationCategory category, ModerationMatchMode mode) {
|
||||
return new Entry(phrase, category, mode, normalize(phrase));
|
||||
}
|
||||
}
|
||||
|
||||
/** Trafienie: wpis w pierwotnej pisowni i jego kategoria. */
|
||||
public record Hit(String phrase, ModerationCategory category) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pierwsze trafienie w tresci albo null.
|
||||
*/
|
||||
public static Hit find(String text, List<Entry> entries) {
|
||||
if (text == null || text.isBlank() || entries.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String spaced = normalize(text);
|
||||
String removed = collapse(mapChars(text, true));
|
||||
String joined = joinSingleLetters(spaced);
|
||||
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
for (String variant : List.of(spaced, removed, joined)) {
|
||||
for (String token : variant.split(" ")) {
|
||||
if (!token.isEmpty()) tokens.add(token);
|
||||
}
|
||||
}
|
||||
List<String> texts = List.of(" " + spaced + " ", " " + removed + " ", " " + joined + " ");
|
||||
|
||||
for (Entry entry : entries) {
|
||||
String word = entry.normalized();
|
||||
if (word == null || word.isBlank()) continue;
|
||||
|
||||
if (word.indexOf(' ') >= 0) {
|
||||
// fraza: szukana w calym tekscie, od poczatku slowa
|
||||
for (String t : texts) {
|
||||
if (t.contains(" " + word)) return new Hit(entry.phrase(), entry.category());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (String token : tokens) {
|
||||
boolean match = switch (entry.mode()) {
|
||||
case WORD -> token.equals(word);
|
||||
case PREFIX -> token.startsWith(word);
|
||||
case CONTAINS -> token.contains(word);
|
||||
};
|
||||
if (match) return new Hit(entry.phrase(), entry.category());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Postac porownywana: male litery a-z, pojedyncze spacje, bez powtorzen.
|
||||
*/
|
||||
public static String normalize(String text) {
|
||||
if (text == null) return "";
|
||||
return collapse(mapChars(text, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zamiana znakow.
|
||||
*
|
||||
* @param removeJoiners czy znaki laczace wewnatrz slowa (kropka, myslnik,
|
||||
* podkreslnik, gwiazdka, apostrof) usuwac, zamiast
|
||||
* zamieniac na spacje
|
||||
*/
|
||||
private static String mapChars(String text, boolean removeJoiners) {
|
||||
String lower = text.toLowerCase(Locale.ROOT).replace('ł', 'l');
|
||||
// rozklad znakow i usuniecie znakow diakrytycznych: ą -> a, ś -> s, ż -> z
|
||||
String plain = Normalizer.normalize(lower, Normalizer.Form.NFD).replaceAll("\\p{M}", "");
|
||||
|
||||
StringBuilder out = new StringBuilder(plain.length());
|
||||
for (char c : plain.toCharArray()) {
|
||||
char mapped = switch (c) {
|
||||
case '0' -> 'o';
|
||||
case '1', '!' -> 'i';
|
||||
case '3' -> 'e';
|
||||
case '4', '@' -> 'a';
|
||||
case '5', '$' -> 's';
|
||||
case '7' -> 't';
|
||||
default -> c;
|
||||
};
|
||||
if (mapped >= 'a' && mapped <= 'z') {
|
||||
out.append(mapped);
|
||||
} else if (removeJoiners && (mapped == '.' || mapped == '-' || mapped == '_'
|
||||
|| mapped == '*' || mapped == '\'' || mapped == '`')) {
|
||||
// pominiete: "k.u.r.w.a" daje "kurwa"
|
||||
} else {
|
||||
out.append(' ');
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** Zwija powtorzenia liter i spacji, obcina brzegi. */
|
||||
private static String collapse(String text) {
|
||||
StringBuilder out = new StringBuilder(text.length());
|
||||
char previous = 0;
|
||||
for (char c : text.toCharArray()) {
|
||||
if (c == previous) continue;
|
||||
out.append(c);
|
||||
previous = c;
|
||||
}
|
||||
return out.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Skleja ciagi pojedynczych liter rozdzielonych spacjami: "k u r w a" daje
|
||||
* "kurwa". Pojedyncza litera miedzy zwyklymi slowami ("a", "i", "w") zostaje.
|
||||
*/
|
||||
private static String joinSingleLetters(String normalized) {
|
||||
List<String> result = new ArrayList<>();
|
||||
StringBuilder run = new StringBuilder();
|
||||
for (String token : normalized.split(" ")) {
|
||||
if (token.length() == 1) {
|
||||
run.append(token);
|
||||
continue;
|
||||
}
|
||||
flush(run, result);
|
||||
if (!token.isEmpty()) result.add(token);
|
||||
}
|
||||
flush(run, result);
|
||||
return collapse(String.join(" ", result));
|
||||
}
|
||||
|
||||
private static void flush(StringBuilder run, List<String> result) {
|
||||
if (run.isEmpty()) return;
|
||||
result.add(run.toString());
|
||||
run.setLength(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.softspm.webinar.repository;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationWord;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ModerationWordRepository extends JpaRepository<ModerationWord, Long> {
|
||||
|
||||
/** Wpisy uzywane przez filtr. Wylaczone zostaja w bazie, ale nie filtruja. */
|
||||
List<ModerationWord> findByActiveTrue();
|
||||
|
||||
/**
|
||||
* Lista w panelu, z wyszukiwaniem i filtrem kategorii.
|
||||
*
|
||||
* `cast(:x as ...)` przy KAZDYM parametrze opcjonalnym jest obowiazkowe:
|
||||
* sterownik wysyla null bez typu i bez rzutowania lista przestaje dzialac
|
||||
* dokladnie wtedy, gdy filtr nie jest uzywany.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT w FROM ModerationWord w
|
||||
WHERE (cast(:q as string) IS NULL
|
||||
OR lower(w.phrase) LIKE lower(concat('%', cast(:q as string), '%')))
|
||||
AND (cast(:category as string) IS NULL OR w.category = :category)
|
||||
""")
|
||||
Page<ModerationWord> search(@Param("q") String q,
|
||||
@Param("category") ModerationCategory category,
|
||||
Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Slownik moderacji czatu i pytan.
|
||||
--
|
||||
-- PO CO
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Wiadomosc na czacie trafia do wszystkich na sali w chwili wyslania. Przy
|
||||
-- dwustu osobach reczne usuniecie wulgaryzmu przychodzi za pozno: wszyscy go
|
||||
-- juz przeczytali. Filtr sprawdza tresc PRZED publikacja, a trafienie
|
||||
-- wstrzymuje wiadomosc do decyzji prowadzacego.
|
||||
--
|
||||
-- WSTRZYMANIE, NIE KASOWANIE
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Lista slow nie rozumie kontekstu i bedzie sie mylic w obie strony. Dlatego
|
||||
-- trafienie niczego nie kasuje ani nie odrzuca samo: wiadomosc czeka jako
|
||||
-- NEW, a prowadzacy ja publikuje albo odrzuca. Falszywe trafienie kosztuje
|
||||
-- wtedy jedno klikniecie, a nie utracona wypowiedz uczestnika.
|
||||
--
|
||||
-- DOPASOWANIE
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- WORD - cale slowo ("dupa", ale nie "duplikat"),
|
||||
-- PREFIX - poczatek slowa, czyli wszystkie odmiany ("debil", "debilu"),
|
||||
-- CONTAINS - fragment slowa, takze w zlozeniach ("zajebisty", "spierdalaj").
|
||||
-- Wpis z odstepem (np. "pornografia dziecieca") jest fraza i szukany jest
|
||||
-- w calym tekscie. Tekst i wpisy sa normalizowane tak samo (bez ogonkow,
|
||||
-- bez wielkich liter, cyfry zamienione na litery, powtorzenia zwiniete),
|
||||
-- wiec wpis podaje sie w zwyklej pisowni, a nie we wszystkich wariantach.
|
||||
|
||||
CREATE TABLE moderation_words (
|
||||
id bigserial PRIMARY KEY,
|
||||
phrase varchar(100) NOT NULL,
|
||||
category varchar(20) NOT NULL,
|
||||
match_mode varchar(20) NOT NULL,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chk_moderation_words_category
|
||||
CHECK (category IN ('VULGAR', 'INSULT', 'CHILD_ABUSE')),
|
||||
CONSTRAINT chk_moderation_words_match_mode
|
||||
CHECK (match_mode IN ('WORD', 'PREFIX', 'CONTAINS'))
|
||||
);
|
||||
|
||||
-- ten sam wpis dwa razy dawalby dwa wiersze do edycji i jeden efekt
|
||||
CREATE UNIQUE INDEX uq_moderation_words_phrase ON moderation_words (lower(phrase));
|
||||
|
||||
-- Ktore slowo wstrzymalo wiadomosc. Null, gdy wiadomosc przeszla bez trafienia.
|
||||
-- Tekst, a nie klucz obcy: wpis moze zostac zmieniony albo skasowany, a panel
|
||||
-- ma dalej pokazac, dlaczego wiadomosc zatrzymano.
|
||||
ALTER TABLE interactions ADD COLUMN moderation_hit varchar(100);
|
||||
|
||||
-- Slownik startowy.
|
||||
--
|
||||
-- Punkt wyjscia, nie zamknieta lista: administrator dopisuje i usuwa wpisy
|
||||
-- w panelu. Wpisy w CONTAINS dobrane tak, zeby nie trafialy w zwykle slowa
|
||||
-- (np. "dup" jako fragment trafialby w "duplikat", wiec "dupa" jest WORD).
|
||||
INSERT INTO moderation_words (phrase, category, match_mode) VALUES
|
||||
-- wulgaryzmy
|
||||
('kurw', 'VULGAR', 'CONTAINS'),
|
||||
('kurew', 'VULGAR', 'CONTAINS'),
|
||||
('chuj', 'VULGAR', 'CONTAINS'),
|
||||
('huj', 'VULGAR', 'CONTAINS'),
|
||||
('jeb', 'VULGAR', 'CONTAINS'),
|
||||
('pierdol', 'VULGAR', 'CONTAINS'),
|
||||
('pierdal', 'VULGAR', 'CONTAINS'),
|
||||
('pizd', 'VULGAR', 'CONTAINS'),
|
||||
('cip', 'VULGAR', 'PREFIX'),
|
||||
('fiut', 'VULGAR', 'PREFIX'),
|
||||
('kutas', 'VULGAR', 'PREFIX'),
|
||||
('dupa', 'VULGAR', 'WORD'),
|
||||
('dupy', 'VULGAR', 'WORD'),
|
||||
('dupę', 'VULGAR', 'WORD'),
|
||||
('dupie', 'VULGAR', 'WORD'),
|
||||
('dupek', 'VULGAR', 'PREFIX'),
|
||||
('srać', 'VULGAR', 'PREFIX'),
|
||||
('sraj', 'VULGAR', 'PREFIX'),
|
||||
('zesrać', 'VULGAR', 'PREFIX'),
|
||||
('gówn', 'VULGAR', 'PREFIX'),
|
||||
('chwdp', 'VULGAR', 'WORD'),
|
||||
('fuck', 'VULGAR', 'CONTAINS'),
|
||||
('shit', 'VULGAR', 'WORD'),
|
||||
('bitch', 'VULGAR', 'PREFIX'),
|
||||
('cunt', 'VULGAR', 'PREFIX'),
|
||||
('asshole', 'VULGAR', 'CONTAINS'),
|
||||
-- obelgi
|
||||
('debil', 'INSULT', 'PREFIX'),
|
||||
('idiot', 'INSULT', 'PREFIX'),
|
||||
('kretyn', 'INSULT', 'PREFIX'),
|
||||
('matoł', 'INSULT', 'PREFIX'),
|
||||
('palant', 'INSULT', 'PREFIX'),
|
||||
('cwel', 'INSULT', 'PREFIX'),
|
||||
('ciul', 'INSULT', 'PREFIX'),
|
||||
('złamas', 'INSULT', 'PREFIX'),
|
||||
('frajer', 'INSULT', 'PREFIX'),
|
||||
('szmat', 'INSULT', 'PREFIX'),
|
||||
('dziwk', 'INSULT', 'PREFIX'),
|
||||
('suka', 'INSULT', 'WORD'),
|
||||
('suko', 'INSULT', 'WORD'),
|
||||
-- cale slowa, bo poczatek "ciot" trafialby w "ciotka"
|
||||
('ciota', 'INSULT', 'WORD'),
|
||||
('cioty', 'INSULT', 'WORD'),
|
||||
('cioto', 'INSULT', 'WORD'),
|
||||
('pedał', 'INSULT', 'WORD'),
|
||||
('pedały', 'INSULT', 'WORD'),
|
||||
('czarnuch', 'INSULT', 'PREFIX'),
|
||||
('ciapat', 'INSULT', 'PREFIX'),
|
||||
('żydek', 'INSULT', 'WORD'),
|
||||
('nigger', 'INSULT', 'PREFIX'),
|
||||
('nigga', 'INSULT', 'PREFIX'),
|
||||
('faggot', 'INSULT', 'PREFIX'),
|
||||
('retard', 'INSULT', 'PREFIX'),
|
||||
-- pedofilia i wykorzystywanie dzieci
|
||||
('pedofil', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('pedo', 'CHILD_ABUSE', 'WORD'),
|
||||
('pornografia dziecięca', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('pornografii dziecięcej', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('dziecięca pornografia', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('dziecięce porno', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('porno z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('seks z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('sex z dzie', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('seks z nieletni', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('molestowanie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('molestowania dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('nagie dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('nagich dzieci', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('child porn', 'CHILD_ABUSE', 'CONTAINS'),
|
||||
('lolicon', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('shotacon', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('jailbait', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('preteen', 'CHILD_ABUSE', 'PREFIX'),
|
||||
('pthc', 'CHILD_ABUSE', 'WORD');
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.softspm.webinar.moderation;
|
||||
|
||||
import com.softspm.webinar.domain.ModerationCategory;
|
||||
import com.softspm.webinar.domain.ModerationMatchMode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Filtr slownika moderacji, sprawdzany na slowniku startowym z migracji.
|
||||
*
|
||||
* Slownik czytany jest wprost z pliku V31, a nie przepisany do testu: test
|
||||
* sprawdza dokladnie to, co trafi na kazda nowa instancje.
|
||||
*
|
||||
* Obie strony sa rownie wazne. Filtr, ktory przepuszcza "k.u.r.w.a", nie
|
||||
* chroni sali; filtr, ktory wstrzymuje "duplikat faktury" albo "ciotka",
|
||||
* zasypuje prowadzacego falszywymi alarmami, az przestanie je czytac.
|
||||
*/
|
||||
class WordFilterTest {
|
||||
|
||||
private static final List<WordFilter.Entry> SEED = loadSeed();
|
||||
|
||||
private static List<WordFilter.Entry> loadSeed() {
|
||||
try {
|
||||
String sql = Files.readString(
|
||||
Path.of("src/main/resources/db/migration/V31__moderation_words.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
// tylko czesc INSERT: wyzej sa listy wartosci ograniczen CHECK
|
||||
String inserts = sql.substring(sql.indexOf("INSERT INTO moderation_words"));
|
||||
Matcher m = Pattern.compile("\\('([^']+)', '(\\w+)', '(\\w+)'\\)").matcher(inserts);
|
||||
List<WordFilter.Entry> entries = new ArrayList<>();
|
||||
while (m.find()) {
|
||||
entries.add(WordFilter.Entry.of(m.group(1),
|
||||
ModerationCategory.valueOf(m.group(2)),
|
||||
ModerationMatchMode.valueOf(m.group(3))));
|
||||
}
|
||||
return entries;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void seedIsLoaded() {
|
||||
assertThat(SEED).hasSizeGreaterThan(50);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"ty kurwo",
|
||||
"KURWA",
|
||||
"Kurwą jesteś",
|
||||
"kuuuurwa",
|
||||
"k.u.r.w.a",
|
||||
"k u r w a",
|
||||
"kurw4",
|
||||
"to jest zajebiste",
|
||||
"spierdalaj stąd",
|
||||
"wypierdalaj",
|
||||
"ty debilu",
|
||||
"chuj z tym",
|
||||
"ch_uj",
|
||||
"sp13rd0l",
|
||||
"pizda",
|
||||
})
|
||||
void holdsVulgarAndInsults(String text) {
|
||||
assertThat(WordFilter.find(text, SEED)).as(text).isNotNull();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"pedofil",
|
||||
"Pedofilia to przestępstwo",
|
||||
"pornografia dziecięca",
|
||||
"szukam pornografii dziecięcej",
|
||||
"seks z dziećmi",
|
||||
})
|
||||
void holdsChildAbuse(String text) {
|
||||
WordFilter.Hit hit = WordFilter.find(text, SEED);
|
||||
assertThat(hit).as(text).isNotNull();
|
||||
assertThat(hit.category()).isEqualTo(ModerationCategory.CHILD_ABUSE);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {
|
||||
"Dzień dobry, mam pytanie o przyłącze wodociągowe",
|
||||
"Proszę o duplikat faktury",
|
||||
"Moja ciotka też pracuje w wodociągach",
|
||||
"Chodzi głównie o przepompownię ścieków",
|
||||
"Gnojowica i osady ściekowe",
|
||||
"Jaki jest dopuszczalny poziom chloru?",
|
||||
"Hydrant przy ulicy Kurkowej",
|
||||
"principal i sprawozdanie finansowe",
|
||||
"Szkolenie z kanalizacji deszczowej",
|
||||
"Czy będzie nagranie?",
|
||||
"Dziękuję, świetny webinar!",
|
||||
})
|
||||
void letsOrdinaryMessagesThrough(String text) {
|
||||
assertThat(WordFilter.find(text, SEED)).as(text).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void speakerWordsAreNotSpecialHere() {
|
||||
// filtr nie wie, kto pisze: pomijanie prowadzacych robi InteractionService
|
||||
assertThat(WordFilter.find("kurwa", SEED)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyTextPasses() {
|
||||
assertThat(WordFilter.find("", SEED)).isNull();
|
||||
assertThat(WordFilter.find(" ", SEED)).isNull();
|
||||
assertThat(WordFilter.find(null, SEED)).isNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user