Moderacja wyrazow: slownik i wstrzymywanie wiadomosci do akceptacji
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:
2026-09-28 09:48:39 +02:00
parent c53ad95104
commit dda54f00a1
22 changed files with 1457 additions and 16 deletions
+26
View File
@@ -1683,6 +1683,32 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
### Moderacja wyrazow
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
`/admin/moderation-words/**`, filtr `WordFilter`.
- **Trafienie WSTRZYMUJE, nigdy nie kasuje.** Wiadomosc czatu albo pytanie
uczestnika z trafieniem dostaje status NEW i `moderation_hit`, widzi je tylko
panel prowadzenia, a prowadzacy publikuje albo odrzuca. Lista slow nie rozumie
kontekstu, wiec falszywe trafienie ma kosztowac jedno klikniecie.
- **Filtr dziala na serwerze, przed publikacja** (`InteractionService.postChat`
i `postQuestion`). Wiadomosci prowadzacych nie sa filtrowane.
- **Wiadomosc i wpis przechodza przez TA SAMA normalizacje**: bez ogonkow i
wielkich liter, cyfry na litery, powtorzenia zwiniete, rozstrzelenia sklejone.
Wpis podaje sie w zwyklej pisowni. Nie dopisywac wariantow typu "kurw4".
- **Tryb CONTAINS tylko dla rdzeni, ktore nie wystepuja w zwyklych slowach.**
"dup" jako fragment trafialby w "duplikat", "ciot" w "ciotka": takie wpisy sa
WORD. `WordFilterTest` czyta slownik wprost z migracji i sprawdza obie strony,
takze slownictwo wodociagowe; sprawdzony w obie strony.
- **Slownik w pamieci, uniewazniany PO zatwierdzeniu zmiany.** Uniewaznienie
w trakcie transakcji pozwalaloby rownoleglej wiadomosci wczytac stara wersje
na stale.
- **Awaria filtru przepuszcza wiadomosc** i loguje blad: czat, ktory przestaje
dzialac przez filtr, jest gorszy niz chwila bez filtru.
- `fullChatHistory` zawiera NEW, bo tam leza wiadomosci wstrzymane. Publiczne
`chatHistory` nadal zwraca wylacznie APPROVED.
### Obecni na sali w panelu prowadzenia
Karta "Na sali" (`RoomParticipants`), endpointy `/admin/webinars/{id}/room/participants`.
@@ -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();
}
}
@@ -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();
}
}
+7
View File
@@ -52,6 +52,7 @@ import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage'
import { UserListPage } from './pages/users/UserListPage'
import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage'
import { ResourcesPage } from './pages/resources/ResourcesPage'
import { ModerationWordsPage } from './pages/moderation/ModerationWordsPage'
import {
AcademyChoicePage,
AcademyPasswordResetPage,
@@ -340,6 +341,12 @@ export function App() {
</RequireAdmin>
}
/>
{/*
Slownik moderacji: ADMIN i MODERATOR, tak jak backend (/admin/**).
Prowadzacy dopisuje slowo zauwazone w trakcie spotkania bez szukania
administratora.
*/}
<Route path="moderacja-wyrazow" element={<ModerationWordsPage />} />
{/*
Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac,
wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend
+2
View File
@@ -47,6 +47,7 @@ import {
Search,
Send,
Settings,
ShieldAlert,
Trash2,
Upload,
UserPlus,
@@ -117,6 +118,7 @@ const ICONS = {
search: Search,
send: Send,
settings: Settings,
'shield-alert': ShieldAlert,
trash: Trash2,
upload: Upload,
'user-plus': UserPlus,
+2
View File
@@ -104,6 +104,8 @@ export const NAV_AREAS: NavArea[] = [
export const NAV_COMMON: NavItem[] = [
{ to: panelPath('/kontakty'), label: 'Kontakty', icon: 'users' },
{ to: panelPath('/banery'), label: 'Banery', icon: 'image', roles: ['ADMIN'] },
// bez ograniczenia roli: prowadzacy (MODERATOR) ma dopisac slowo zauwazone w trakcie spotkania
{ to: panelPath('/moderacja-wyrazow'), label: 'Moderacja wyrazów', icon: 'shield-alert' },
{ to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] },
{ to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] },
{ to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] },
+2
View File
@@ -60,6 +60,8 @@ export type ModeratorInteractionView = {
authorChatMuted: boolean
authorRoomBanned: boolean
createdAt: string
/** Slowo ze slownika moderacji, ktore wstrzymalo wiadomosc; null bez trafienia. */
moderationHit: string | null
}
type History = {
@@ -0,0 +1,436 @@
import { useEffect, useState } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts'
import {
Alert,
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
IconButton,
Input,
Select,
Spinner,
Textarea,
} from '../../components/ui'
type Category = 'VULGAR' | 'INSULT' | 'CHILD_ABUSE'
type MatchMode = 'WORD' | 'PREFIX' | 'CONTAINS'
type Word = {
id: number
phrase: string
category: Category
matchMode: MatchMode
active: boolean
createdAt: string
}
type Page<T> = {
content: T[]
totalElements: number
totalPages: number
number: number
}
type CheckResult = { held: boolean; phrase: string | null; category: Category | null }
const CATEGORY_LABEL: Record<Category, string> = {
VULGAR: 'Wulgaryzm',
INSULT: 'Obelga',
CHILD_ABUSE: 'Pedofilia i wykorzystywanie dzieci',
}
const CATEGORY_TONE: Record<Category, 'warn' | 'info' | 'alarm'> = {
VULGAR: 'warn',
INSULT: 'info',
CHILD_ABUSE: 'alarm',
}
const MODE_LABEL: Record<MatchMode, string> = {
WORD: 'Całe słowo',
PREFIX: 'Początek słowa (wszystkie odmiany)',
CONTAINS: 'Fragment słowa (także w złożeniach)',
}
/** Skutek wpisu zwyklym zdaniem, zgodnie z zasada przyjaznego interfejsu. */
function describeEffect(phrase: string, mode: MatchMode): string {
const p = phrase.trim()
if (!p) return 'Wpisz słowo, a zobaczysz, jakie wiadomości zostaną zatrzymane.'
if (/\s/.test(p)) return `Zatrzyma wiadomości zawierające frazę „${p}”.`
switch (mode) {
case 'WORD':
return `Zatrzyma wiadomości ze słowem „${p}”, ale nie z dłuższymi słowami, które się od niego zaczynają.`
case 'PREFIX':
return `Zatrzyma wiadomości ze słowami zaczynającymi się od „${p}”, czyli także jego odmiany.`
case 'CONTAINS':
return `Zatrzyma wiadomości ze słowami zawierającymi „${p}” w dowolnym miejscu, także w złożeniach.`
}
}
type FormState = { phrase: string; category: Category; matchMode: MatchMode; active: boolean }
const EMPTY_FORM: FormState = { phrase: '', category: 'VULGAR', matchMode: 'PREFIX', active: true }
/**
* Slownik moderacji czatu i pytan.
*
* Wpis nie usuwa wiadomosci, tylko WSTRZYMUJE ja do decyzji prowadzacego:
* lista slow nie rozumie kontekstu, wiec falszywe trafienie ma kosztowac
* jedno klikniecie, a nie utracona wypowiedz. Dlatego skutek kazdego wpisu jest
* opisany zdaniem, a pole "Sprawdz tekst" pozwala zobaczyc dzialanie filtru
* przed webinarem, a nie w jego trakcie.
*/
export function ModerationWordsPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const [search, setSearch] = useState('')
const [query, setQuery] = useState('')
const [category, setCategory] = useState<Category | ''>('')
const [page, setPage] = useState(0)
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [editing, setEditing] = useState<Word | null>(null)
const [errors, setErrors] = useState<Record<string, string>>({})
const [sample, setSample] = useState('')
const [checkResult, setCheckResult] = useState<CheckResult | null>(null)
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(search.trim())
setPage(0)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
const words = useQuery({
queryKey: ['moderation-words', query, category, page],
queryFn: async () =>
(
await client.get<Page<Word>>('/admin/moderation-words', {
params: { q: query || undefined, category: category || undefined, page, size: PAGE_SIZE },
})
).data,
placeholderData: keepPreviousData,
})
const refresh = () => queryClient.invalidateQueries({ queryKey: ['moderation-words'] })
const save = useMutation({
mutationFn: async (value: FormState) =>
editing
? client.put(`/admin/moderation-words/${editing.id}`, value)
: client.post('/admin/moderation-words', value),
onSuccess: () => {
toasts.success(editing ? 'Wpis zapisany' : 'Wpis dodany')
setForm(EMPTY_FORM)
setEditing(null)
setErrors({})
setCheckResult(null)
void refresh()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać wpisu'))
},
})
const toggle = useMutation({
mutationFn: (word: Word) =>
client.put(`/admin/moderation-words/${word.id}`, {
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: !word.active,
}),
onSuccess: (_, word) => {
toasts.success(word.active ? `Wyłączono: „${word.phrase}”` : `Włączono: „${word.phrase}”`)
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić wpisu')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/moderation-words/${id}`),
onSuccess: () => {
toasts.success('Wpis usunięty')
void refresh()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu')),
})
const check = useMutation({
mutationFn: async (text: string) =>
(await client.post<CheckResult>('/admin/moderation-words/check', { text })).data,
onSuccess: (data) => setCheckResult(data),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się sprawdzić tekstu')),
})
function startEdit(word: Word) {
setEditing(word)
setErrors({})
setForm({
phrase: word.phrase,
category: word.category,
matchMode: word.matchMode,
active: word.active,
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function cancelEdit() {
setEditing(null)
setErrors({})
setForm(EMPTY_FORM)
}
async function handleDelete(word: Word) {
const ok = await confirm({
title: `Usunąć wpis „${word.phrase}”?`,
message:
'Wiadomości z tym słowem przestaną być wstrzymywane. Jeśli chcesz tylko na chwilę '
+ 'przestać go sprawdzać, wyłącz wpis zamiast go usuwać.',
confirmLabel: 'Usuń wpis',
destructive: true,
})
if (ok) remove.mutate(word.id)
}
const data = words.data
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-slate-900">Moderacja wyrazów</h1>
<p className="mt-1 text-sm text-slate-500">
Wiadomość na czacie albo pytanie uczestnika, które zawiera słowo z tej listy, nie trafia do
sali. Czeka w panelu prowadzenia, a prowadzący ją publikuje albo odrzuca.
</p>
</div>
<Card
title={editing ? `Edycja wpisu „${editing.phrase}”` : 'Dodaj wpis'}
description="Wpisz słowo w zwykłej pisowni. Wielkość liter, polskie znaki, cyfry zamiast liter (np. 3 zamiast e) i rozstrzelenia (np. k.u.r.w.a) są rozpoznawane automatycznie."
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
save.mutate(form)
}}
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Słowo albo fraza" error={errors.phrase}>
<Input
value={form.phrase}
maxLength={100}
onChange={(event) => setForm({ ...form, phrase: event.target.value })}
placeholder="np. debil"
/>
</Field>
<Field label="Kategoria" error={errors.category}>
<Select
value={form.category}
onChange={(event) => setForm({ ...form, category: event.target.value as Category })}
>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Sposób dopasowania" error={errors.matchMode}>
<Select
value={form.matchMode}
onChange={(event) => setForm({ ...form, matchMode: event.target.value as MatchMode })}
>
{(Object.keys(MODE_LABEL) as MatchMode[]).map((value) => (
<option key={value} value={value}>
{MODE_LABEL[value]}
</option>
))}
</Select>
</Field>
<Field label="Stan">
<label className="flex min-h-[44px] items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.active}
onChange={(event) => setForm({ ...form, active: event.target.checked })}
className="h-5 w-5 rounded border-slate-300"
/>
Wpis włączony
</label>
</Field>
</div>
<p className="text-sm text-slate-600">{describeEffect(form.phrase, form.matchMode)}</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" icon={editing ? 'save' : 'plus'} disabled={save.isPending}>
{editing ? 'Zapisz zmiany' : 'Dodaj wpis'}
</Button>
{editing && (
<Button type="button" variant="secondary" onClick={cancelEdit}>
Anuluj
</Button>
)}
</div>
</form>
</Card>
<Card
title="Sprawdź tekst"
description="Wklej przykładową wiadomość, żeby zobaczyć, czy filtr ją zatrzyma. Nic nie jest zapisywane ani wysyłane."
>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
check.mutate(sample)
}}
>
<Field label="Treść wiadomości" wide>
<Textarea
rows={2}
maxLength={2000}
value={sample}
onChange={(event) => {
setSample(event.target.value)
setCheckResult(null)
}}
/>
</Field>
<Button type="submit" variant="secondary" icon="search" disabled={check.isPending || !sample.trim()}>
Sprawdź
</Button>
{checkResult && (
checkResult.held ? (
<Alert tone="warn" icon="alert-triangle">
Wiadomość zostanie wstrzymana do akceptacji prowadzącego. Trafienie: „{checkResult.phrase}”
{checkResult.category && ` (${CATEGORY_LABEL[checkResult.category].toLowerCase()})`}.
</Alert>
) : (
<Alert tone="ok" icon="check-circle">
Wiadomość przejdzie od razu, żaden wpis jej nie zatrzymuje.
</Alert>
)
)}
</form>
</Card>
<Card title="Lista wpisów">
<div className="mb-3 grid gap-2 sm:grid-cols-[1fr_auto]">
<div className="relative">
<Icon
name="search"
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Szukaj wpisu"
aria-label="Szukaj wpisu"
className="pl-9"
/>
</div>
<Select
value={category}
aria-label="Kategoria"
onChange={(event) => {
setCategory(event.target.value as Category | '')
setPage(0)
}}
>
<option value="">Wszystkie kategorie</option>
{(Object.keys(CATEGORY_LABEL) as Category[]).map((value) => (
<option key={value} value={value}>
{CATEGORY_LABEL[value]}
</option>
))}
</Select>
</div>
{words.isPending && <Spinner />}
{words.isError && (
<ErrorState message={errorMessage(words.error, 'Nie udało się wczytać słownika')} />
)}
{data && data.content.length === 0 && (
<EmptyState
icon="shield-alert"
title={query || category ? 'Brak wpisów spełniających warunki' : 'Słownik jest pusty'}
description={
query || category
? 'Zmień wyszukiwanie albo kategorię.'
: 'Dodaj pierwszy wpis w formularzu powyżej.'
}
/>
)}
{data && data.content.length > 0 && (
<ul className="divide-y divide-slate-100">
{data.content.map((word) => (
<li key={word.id} className="flex flex-wrap items-center gap-2 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={`hx-data text-sm font-semibold ${word.active ? 'text-slate-900' : 'text-slate-400 line-through'}`}
>
{word.phrase}
</span>
<Badge tone={CATEGORY_TONE[word.category]}>{CATEGORY_LABEL[word.category]}</Badge>
{!word.active && <Badge tone="neutral">wyłączony</Badge>}
</div>
<p className="text-xs text-slate-500">{MODE_LABEL[word.matchMode]}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<IconButton
icon={word.active ? 'eye-off' : 'eye'}
label={word.active ? `Wyłącz wpis „${word.phrase}”` : `Włącz wpis „${word.phrase}”`}
onClick={() => toggle.mutate(word)}
disabled={toggle.isPending}
/>
<IconButton
icon="pencil"
label={`Edytuj wpis „${word.phrase}”`}
onClick={() => startEdit(word)}
/>
<IconButton
icon="trash"
label={`Usuń wpis „${word.phrase}”`}
onClick={() => void handleDelete(word)}
disabled={remove.isPending}
/>
</div>
</li>
))}
</ul>
)}
{data && (
<div className="mt-3">
<Pagination
page={data.number}
totalPages={data.totalPages}
totalElements={data.totalElements}
onChange={setPage}
itemLabel="wpisów"
/>
</div>
)}
</Card>
</div>
)
}
@@ -12,6 +12,7 @@ import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
import { RoomParticipants } from './RoomParticipants'
import { useConfirm } from '../../components/Confirm'
import { panelPath } from '../../lib/routes'
import { plural } from '../../lib/dates'
/**
* Panel prowadzacego: czat na zywo i kolejka pytan.
@@ -267,9 +268,18 @@ export function HostConsolePage() {
setChat((current) => current.filter((c) => c.id !== item.id))
return
}
/*
* Istniejaca pozycja jest PODMIENIANA, a nie pomijana: wiadomosc
* wstrzymana przez slownik wraca tu jako APPROVED po opublikowaniu
* i musi zgubic oznaczenie "wstrzymana".
*/
setChat((current) =>
current.some((c) => c.id === item.id) ? current : [...current, item])
setNewChat((n) => n + 1)
current.some((c) => c.id === item.id)
? current.map((c) => (c.id === item.id ? item : c))
: [...current, item])
if (event.kind !== 'APPROVED') setNewChat((n) => n + 1)
// wstrzymana wiadomosc czeka na decyzje, wiec prowadzacy ma ja uslyszec
if (event.kind === 'CHAT_HELD' && soundRef.current) playChime()
return
}
@@ -355,6 +365,7 @@ export function HostConsolePage() {
}
const pending = questions.filter((q) => q.status === 'NEW')
const heldChat = chat.filter((c) => c.status === 'NEW').length
const published = questions
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
.sort((a, b) => {
@@ -428,6 +439,11 @@ export function HostConsolePage() {
>
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
<span className="hx-data text-xs text-slate-400">
{heldChat > 0 && (
<span className="mr-2 font-sans font-medium text-warn-600">
{heldChat} {plural(heldChat, 'wstrzymana', 'wstrzymane', 'wstrzymanych')}
</span>
)}
{chat.length}
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
</span>
@@ -435,6 +451,7 @@ export function HostConsolePage() {
<ChatColumn
messages={chat}
onApprove={(itemId) => act(itemId, 'approve')}
onReject={(itemId) => act(itemId, 'reject')}
onModerate={moderateParticipant}
/>
@@ -472,10 +489,12 @@ export function HostConsolePage() {
function ChatColumn({
messages,
onApprove,
onReject,
onModerate,
}: {
messages: ModeratorInteractionView[]
onApprove: (id: number) => void
onReject: (id: number) => void
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean, authorName?: string) => void
}) {
@@ -489,8 +508,18 @@ function ChatColumn({
{messages.length === 0 && (
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
)}
{messages.map((message) => (
<div key={message.id} className="group flex items-start gap-2 text-sm">
{messages.map((message) => {
/*
* Wiadomosc wstrzymana przez slownik moderacji. Widzi ja tylko panel;
* decyzja jest na wierzchu, a nie pod najechaniem myszy, bo bez niej
* wiadomosc nigdy nie trafi na sale.
*/
const held = message.status === 'NEW'
return (
<div
key={message.id}
className={`group flex items-start gap-2 text-sm ${held ? 'rounded-md border border-warn-500/30 bg-warn-50 p-2' : ''}`}
>
<div className="min-w-0 flex-1">
<span
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
@@ -508,6 +537,23 @@ function ChatColumn({
</span>
)}
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
{held && (
<>
<p className="mt-1 text-xs text-warn-600">
Wstrzymana przez filtr słów
{message.moderationHit && <>: „{message.moderationHit}”</>}. Nikt poza prowadzącymi jej
nie widzi.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Button size="sm" icon="check" onClick={() => onApprove(message.id)}>
Opublikuj
</Button>
<Button size="sm" variant="secondary" icon="x" onClick={() => onReject(message.id)}>
Odrzuć
</Button>
</div>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
@@ -553,7 +599,8 @@ function ChatColumn({
</button>
</div>
</div>
))}
)
})}
<div ref={bottomRef} />
</div>
)
@@ -581,6 +628,11 @@ function QuestionsColumn({
{question.content}
</p>
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
{question.moderationHit && (
<p className="mt-0.5 text-xs text-warn-600">
Wstrzymane przez filtr słów: „{question.moderationHit}”
</p>
)}
<div className="mt-2 flex gap-2">
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
Zatwierdź
@@ -479,6 +479,12 @@ function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: W
<span className="font-medium text-slate-900">{message.authorName}</span>
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
{/* wstrzymana przez slownik i nieopublikowana: uczestnicy jej nie widzieli */}
{message.status === 'NEW' && (
<Badge tone="warn">
wstrzymana przez filtr{'moderationHit' in message && message.moderationHit ? `: „${message.moderationHit}”` : ''}
</Badge>
)}
</div>
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
{date} {time}