%%%------------------------------------------------------------------- %%% Автомодерация: сканирование бан-слов, политики, soft-hide, очередь. %%%------------------------------------------------------------------- -module(logic_automoderation). -include("records.hrl"). -export([ scan_text/1, scan_texts/1, evaluate_texts/1, apply_after_save/4, soft_hide/3, restore_entity/2, censor_text/2, get_settings/0, update_settings/2, list_hits/2, resolve_hit/3, settings_to_json/1, hit_to_json/1, record_report_threshold_hit/2, reason_reports/0 ]). -define(SYSTEM_ID, <<"system">>). -define(SYSTEM_EMAIL, <<"system@eventhub">>). -define(REASON_KEYWORD, <<"auto:keyword">>). -define(REASON_REPORTS, <<"auto:report_threshold">>). %%%=================================================================== %%% Scan / evaluate %%%=================================================================== -spec scan_text(binary()) -> clean | {hit, [binary()]}. scan_text(Text) when is_binary(Text) -> Settings = core_automod_settings:get(), Words = case catch core_banned_words:list_banned_words() of List when is_list(List) -> [W#banned_word.word || W <- List]; _ -> [] end, Hits = [W || W <- Words, word_matches(Text, W, Settings#automod_settings.match_mode)], case Hits of [] -> clean; _ -> {hit, lists:usort(Hits)} end; scan_text(_) -> clean. -spec scan_texts([binary()]) -> clean | {hit, [binary()]}. scan_texts(Texts) -> lists:foldl(fun(Text, Acc) -> case scan_text(Text) of clean -> Acc; {hit, Words} -> case Acc of clean -> {hit, Words}; {hit, Prev} -> {hit, lists:usort(Prev ++ Words)} end end end, clean, Texts). %% @doc До сохранения: reject | {ok, Action, TextsOut, Words}. %% Action = none | flag | censor. TextsOut — возможно с цензурой. -spec evaluate_texts([binary()]) -> {reject, [binary()]} | {ok, none | flag | censor, [binary()], [binary()]}. evaluate_texts(Texts) -> case scan_texts(Texts) of clean -> {ok, none, Texts, []}; {hit, Words} -> Settings = core_automod_settings:get(), case Settings#automod_settings.keyword_action of reject -> {reject, Words}; censor -> Censored = [censor_text(T, Words) || T <- Texts], {ok, censor, Censored, Words}; flag -> {ok, flag, Texts, Words} end end. %%%=================================================================== %%% After save %%%=================================================================== -spec apply_after_save(calendar | event | review, binary(), none | flag | censor, [binary()]) -> ok. apply_after_save(_Type, _Id, none, _) -> ok; apply_after_save(EntityType, EntityId, Action, Words) when Action =:= flag; Action =:= censor -> case Action of flag -> soft_hide(EntityType, EntityId, ?REASON_KEYWORD); censor -> ok end, {ok, Hit} = core_automod_hit:create(EntityType, EntityId, keyword, Words, Action), log_system(<<"automod_keyword">>, entity_type_bin(EntityType), EntityId, ?REASON_KEYWORD), logic_notification:notify_admin(automod_hit, #{ hit_id => Hit#automod_hit.id, entity_type => entity_type_bin(EntityType), entity_id => EntityId, trigger => <<"keyword">>, action => atom_to_binary(Action, utf8), matched_words => Words }), ok. -spec soft_hide(calendar | event | review, binary(), binary()) -> ok. soft_hide(event, Id, Reason) -> case core_event:get_by_id(Id) of {ok, #event{status = active}} -> _ = core_event:freeze(Id, Reason), ok; _ -> ok end; soft_hide(calendar, Id, Reason) -> case core_calendar:get_by_id(Id) of {ok, #calendar{status = active}} -> _ = core_calendar:freeze(Id, Reason), ok; _ -> ok end; soft_hide(review, Id, Reason) -> case core_review:get_by_id(Id) of {ok, #review{status = visible}} -> _ = core_review:hide(Id, Reason), ok; _ -> ok end. -spec restore_entity(calendar | event | review, binary()) -> ok | {error, term()}. restore_entity(event, Id) -> case core_event:unfreeze(Id, <<"automod:approved">>) of {ok, _} -> ok; Err -> Err end; restore_entity(calendar, Id) -> case core_calendar:unfreeze(Id, <<"automod:approved">>) of {ok, _} -> ok; Err -> Err end; restore_entity(review, Id) -> case core_review:unhide(Id, <<"automod:approved">>) of {ok, _} -> ok; Err -> Err end. %%%=================================================================== %%% Settings / hits (admin) %%%=================================================================== get_settings() -> core_automod_settings:ensure_defaults(), {ok, core_automod_settings:get()}. update_settings(AdminId, Updates) -> case admin_utils:check_role(AdminId, [superadmin, admin]) of true -> case core_automod_settings:update(Updates, AdminId) of {ok, S} -> {ok, S}; Error -> Error end; false -> {error, access_denied} end. list_hits(AdminId, Filters) -> case admin_utils:check_role(AdminId, [superadmin, admin, moderator]) of true -> {ok, core_automod_hit:list(Filters)}; false -> {error, access_denied} end. resolve_hit(AdminId, HitId, Decision) when Decision =:= approved; Decision =:= rejected -> case admin_utils:check_role(AdminId, [superadmin, admin, moderator]) of true -> case core_automod_hit:resolve(HitId, Decision, AdminId) of {ok, Hit} -> case Decision of approved -> _ = restore_entity(Hit#automod_hit.entity_type, Hit#automod_hit.entity_id); rejected -> soft_hide(Hit#automod_hit.entity_type, Hit#automod_hit.entity_id, <<"automod:rejected">>) end, {ok, Hit}; Error -> Error end; false -> {error, access_denied} end. %%%=================================================================== %%% Text helpers %%%=================================================================== -spec censor_text(binary(), [binary()]) -> binary(). censor_text(Text, Words) -> lists:foldl(fun(Word, Acc) -> replace_word(Acc, Word, <<"***">>) end, Text, Words). replace_word(Text, Word, Replacement) -> Settings = core_automod_settings:get(), case Settings#automod_settings.match_mode of substring -> replace_substring_ci(Text, Word, Replacement); word_boundary -> replace_boundary_ci(Text, Word, Replacement) end. word_matches(Text, Word, Mode) -> TextL = unicode:characters_to_list(string:lowercase(Text)), WordL = unicode:characters_to_list(string:lowercase(Word)), case Mode of substring -> string:str(TextL, WordL) > 0; word_boundary -> find_boundary(TextL, WordL, 1) end. find_boundary(_Text, [], _) -> false; find_boundary(Text, Word, Start) when Start > length(Text) -> false; find_boundary(Text, Word, Start) -> Tail = lists:nthtail(Start - 1, Text), case string:str(Tail, Word) of 0 -> false; Rel -> Pos = Start + Rel - 1, BeforeOk = Pos =:= 1 orelse not is_word_char(lists:nth(Pos - 1, Text)), AfterPos = Pos + length(Word), AfterOk = AfterPos > length(Text) orelse not is_word_char(lists:nth(AfterPos, Text)), case BeforeOk andalso AfterOk of true -> true; false -> find_boundary(Text, Word, Pos + 1) end end. is_word_char(C) when C >= $a, C =< $z -> true; is_word_char(C) when C >= $0, C =< $9 -> true; is_word_char($_) -> true; is_word_char(C) when C >= 16#0400, C =< 16#04FF -> true; % Cyrillic block is_word_char(_) -> false. replace_substring_ci(Text, Word, Replacement) -> TextL = string:lowercase(Text), WordL = string:lowercase(Word), case binary:match(TextL, WordL) of nomatch -> Text; {Pos, Len} -> <> = Text, replace_substring_ci(<>, Word, Replacement) end. replace_boundary_ci(Text, Word, Replacement) -> TextList = unicode:characters_to_list(Text), WordList = unicode:characters_to_list(Word), TextLower = unicode:characters_to_list(string:lowercase(Text)), WordLower = unicode:characters_to_list(string:lowercase(Word)), case find_boundary(TextLower, WordLower, 1) of false -> Text; true -> case string:str(TextLower, WordLower) of 0 -> Text; Pos -> %% may be false positive on first substring; walk like find_boundary case find_boundary_pos(TextLower, WordLower, 1) of undefined -> Text; RealPos -> Prefix = lists:sublist(TextList, RealPos - 1), Rest = lists:nthtail(RealPos - 1 + length(WordList), TextList), New = unicode:characters_to_binary(Prefix ++ unicode:characters_to_list(Replacement) ++ Rest), replace_boundary_ci(New, Word, Replacement) end end end. find_boundary_pos(_Text, [], _) -> undefined; find_boundary_pos(Text, Word, Start) when Start > length(Text) -> undefined; find_boundary_pos(Text, Word, Start) -> Tail = lists:nthtail(Start - 1, Text), case string:str(Tail, Word) of 0 -> undefined; Rel -> Pos = Start + Rel - 1, BeforeOk = Pos =:= 1 orelse not is_word_char(lists:nth(Pos - 1, Text)), AfterPos = Pos + length(Word), AfterOk = AfterPos > length(Text) orelse not is_word_char(lists:nth(AfterPos, Text)), case BeforeOk andalso AfterOk of true -> Pos; false -> find_boundary_pos(Text, Word, Pos + 1) end end. %%%=================================================================== %%% JSON / audit helpers %%%=================================================================== settings_to_json(#automod_settings{} = S) -> #{ keyword_action => atom_to_binary(S#automod_settings.keyword_action, utf8), match_mode => atom_to_binary(S#automod_settings.match_mode, utf8), report_threshold => S#automod_settings.report_threshold, updated_at => handler_utils:datetime_to_iso8601(S#automod_settings.updated_at), updated_by => S#automod_settings.updated_by }. hit_to_json(#automod_hit{} = H) -> #{ id => H#automod_hit.id, entity_type => atom_to_binary(H#automod_hit.entity_type, utf8), entity_id => H#automod_hit.entity_id, trigger => atom_to_binary(H#automod_hit.trigger, utf8), matched_words => H#automod_hit.matched_words, action_taken => atom_to_binary(H#automod_hit.action_taken, utf8), status => atom_to_binary(H#automod_hit.status, utf8), created_at => handler_utils:datetime_to_iso8601(H#automod_hit.created_at), resolved_at => case H#automod_hit.resolved_at of undefined -> null; Dt -> handler_utils:datetime_to_iso8601(Dt) end, resolved_by => H#automod_hit.resolved_by }. log_system(Action, EntityType, EntityId, Reason) -> core_admin_audit:log(?SYSTEM_ID, ?SYSTEM_EMAIL, system, Action, EntityType, EntityId, <<"127.0.0.1">>, Reason). entity_type_bin(calendar) -> <<"calendar">>; entity_type_bin(event) -> <<"event">>; entity_type_bin(review) -> <<"review">>. record_report_threshold_hit(EntityType, EntityId) -> Action = case EntityType of review -> hide; _ -> freeze end, soft_hide(EntityType, EntityId, ?REASON_REPORTS), {ok, Hit} = core_automod_hit:create(EntityType, EntityId, report_threshold, [], Action), log_system(<<"automod_report_threshold">>, entity_type_bin(EntityType), EntityId, ?REASON_REPORTS), logic_notification:notify_admin(automod_hit, #{ hit_id => Hit#automod_hit.id, entity_type => entity_type_bin(EntityType), entity_id => EntityId, trigger => <<"report_threshold">>, action => atom_to_binary(Action, utf8) }), ok. reason_reports() -> ?REASON_REPORTS.