diff --git a/include/records.hrl b/include/records.hrl index 8d66b3c..6acb8d0 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -203,6 +203,29 @@ added_at :: calendar:datetime() }). +%% ------------------- Автомодерация ----------------------------------- +-record(automod_settings, { + id :: binary(), % singleton <<"default">> + keyword_action :: reject | flag | censor, + match_mode :: word_boundary | substring, + report_threshold :: pos_integer(), + updated_at :: calendar:datetime(), + updated_by :: binary() +}). + +-record(automod_hit, { + id :: binary(), + entity_type :: calendar | event | review, + entity_id :: binary(), + trigger :: keyword | report_threshold, + matched_words :: [binary()], + action_taken :: reject | flag | censor | freeze | hide, + status :: open | approved | rejected, + created_at :: calendar:datetime(), + resolved_at :: calendar:datetime() | undefined, + resolved_by :: binary() +}). + %% ------------------- Баг-трекер -------------------------------------- -record(ticket, { id :: binary(), diff --git a/src/core/core_automod_hit.erl b/src/core/core_automod_hit.erl new file mode 100644 index 0000000..84816fc --- /dev/null +++ b/src/core/core_automod_hit.erl @@ -0,0 +1,83 @@ +-module(core_automod_hit). +-include("records.hrl"). + +-export([create/5, get_by_id/1, list/0, list/1, resolve/3]). + +-spec create(EntityType :: calendar | event | review, + EntityId :: binary(), + Trigger :: keyword | report_threshold, + MatchedWords :: [binary()], + ActionTaken :: atom()) -> {ok, #automod_hit{}}. +create(EntityType, EntityId, Trigger, MatchedWords, ActionTaken) -> + case lists:member(automod_hit, mnesia:system_info(tables)) of + false -> + {ok, #automod_hit{ + id = <<"transient">>, + entity_type = EntityType, + entity_id = EntityId, + trigger = Trigger, + matched_words = MatchedWords, + action_taken = ActionTaken, + status = open, + created_at = calendar:universal_time(), + resolved_at = undefined, + resolved_by = <<>> + }}; + true -> + Id = infra_utils:generate_id(16), + Hit = #automod_hit{ + id = Id, + entity_type = EntityType, + entity_id = EntityId, + trigger = Trigger, + matched_words = MatchedWords, + action_taken = ActionTaken, + status = open, + created_at = calendar:universal_time(), + resolved_at = undefined, + resolved_by = <<>> + }, + mnesia:dirty_write(Hit), + {ok, Hit} + end. + +-spec get_by_id(binary()) -> {ok, #automod_hit{}} | {error, not_found}. +get_by_id(Id) -> + case mnesia:dirty_read(automod_hit, Id) of + [Hit] -> {ok, Hit}; + [] -> {error, not_found} + end. + +-spec list() -> [#automod_hit{}]. +list() -> + mnesia:dirty_match_object(#automod_hit{_ = '_'}). + +-spec list([{atom(), term()}]) -> [#automod_hit{}]. +list(Filters) -> + lists:filter(fun(H) -> match_filters(H, Filters) end, list()). + +-spec resolve(binary(), approved | rejected, binary()) -> + {ok, #automod_hit{}} | {error, not_found | already_resolved}. +resolve(Id, Status, AdminId) when Status =:= approved; Status =:= rejected -> + case get_by_id(Id) of + {ok, #automod_hit{status = open} = Hit} -> + Updated = Hit#automod_hit{ + status = Status, + resolved_at = calendar:universal_time(), + resolved_by = AdminId + }, + mnesia:dirty_write(Updated), + {ok, Updated}; + {ok, _} -> + {error, already_resolved}; + Error -> + Error + end. + +match_filters(H, Filters) -> + lists:all(fun + ({status, S}) -> H#automod_hit.status =:= S; + ({trigger, T}) -> H#automod_hit.trigger =:= T; + ({entity_type, T}) -> H#automod_hit.entity_type =:= T; + (_) -> true + end, Filters). diff --git a/src/core/core_automod_settings.erl b/src/core/core_automod_settings.erl new file mode 100644 index 0000000..b876553 --- /dev/null +++ b/src/core/core_automod_settings.erl @@ -0,0 +1,83 @@ +-module(core_automod_settings). +-include("records.hrl"). + +-export([get/0, update/2, ensure_defaults/0]). + +-define(ID, <<"default">>). +-define(DEFAULT_ACTION, flag). +-define(DEFAULT_MATCH, word_boundary). +-define(DEFAULT_THRESHOLD, 3). + +-spec get() -> #automod_settings{}. +get() -> + case catch mnesia:dirty_read(automod_settings, ?ID) of + [S] -> S; + [] -> + case catch ensure_defaults() of + ok -> + case catch mnesia:dirty_read(automod_settings, ?ID) of + [S2] -> S2; + _ -> default_record(<<>>) + end; + _ -> + default_record(<<>>) + end; + {'EXIT', _} -> + default_record(<<>>); + _ -> + default_record(<<>>) + end. + +-spec ensure_defaults() -> ok. +ensure_defaults() -> + case lists:member(automod_settings, mnesia:system_info(tables)) of + false -> + ok; + true -> + case mnesia:dirty_read(automod_settings, ?ID) of + [_] -> ok; + [] -> + mnesia:dirty_write(default_record(<<>>)), + ok + end + end. + +-spec update(map() | [{atom(), term()}], binary()) -> + {ok, #automod_settings{}} | {error, term()}. +update(Updates, AdminId) when is_map(Updates) -> + update(maps:to_list(Updates), AdminId); +update(Updates, AdminId) when is_list(Updates) -> + Current = get(), + case apply_updates(Current, Updates) of + {error, _} = Err -> Err; + {ok, Next0} -> + Next = Next0#automod_settings{ + updated_at = calendar:universal_time(), + updated_by = AdminId + }, + mnesia:dirty_write(Next), + {ok, Next} + end. + +default_record(UpdatedBy) -> + #automod_settings{ + id = ?ID, + keyword_action = ?DEFAULT_ACTION, + match_mode = ?DEFAULT_MATCH, + report_threshold = ?DEFAULT_THRESHOLD, + updated_at = calendar:universal_time(), + updated_by = UpdatedBy + }. + +apply_updates(S, []) -> {ok, S}; +apply_updates(S, [{keyword_action, A} | Rest]) + when A =:= reject; A =:= flag; A =:= censor -> + apply_updates(S#automod_settings{keyword_action = A}, Rest); +apply_updates(S, [{match_mode, M} | Rest]) + when M =:= word_boundary; M =:= substring -> + apply_updates(S#automod_settings{match_mode = M}, Rest); +apply_updates(S, [{report_threshold, N} | Rest]) + when is_integer(N), N >= 1 -> + apply_updates(S#automod_settings{report_threshold = N}, Rest); +apply_updates(_S, [{Field, _} | _]) -> + {error, {invalid_field, Field}}. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index a4e9cfc..6a65d3d 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -151,6 +151,10 @@ start_admin_http() -> {"/v1/admin/banned-words/batch", admin_handler_banned_words, []}, {"/v1/admin/banned-words", admin_handler_banned_words, []}, {"/v1/admin/banned-words/:word", admin_handler_banned_words, []}, + % ================== АВТОМОДЕРАЦИЯ ================== + {"/v1/admin/automod/settings", admin_handler_automod, []}, + {"/v1/admin/automod/hits", admin_handler_automod, []}, + {"/v1/admin/automod/hits/:id", admin_handler_automod, []}, % ================== ТИКЕТЫ ================== {"/v1/admin/tickets/stats", admin_handler_ticket_stats, []}, {"/v1/admin/tickets/:id", admin_handler_ticket_by_id, []}, diff --git a/src/handlers/admin/admin_handler_automod.erl b/src/handlers/admin/admin_handler_automod.erl new file mode 100644 index 0000000..e052cc6 --- /dev/null +++ b/src/handlers/admin/admin_handler_automod.erl @@ -0,0 +1,162 @@ +%%%------------------------------------------------------------------- +%%% Admin: настройки автомодерации и очередь hits. +%%%------------------------------------------------------------------- +-module(admin_handler_automod). +-behaviour(cowboy_handler). +-export([init/2, trails/0]). +-include("records.hrl"). + +init(Req, _Opts) -> + Path = cowboy_req:path(Req), + Method = cowboy_req:method(Req), + case {Method, Path} of + {<<"GET">>, <<"/v1/admin/automod/settings">>} -> get_settings(Req); + {<<"PUT">>, <<"/v1/admin/automod/settings">>} -> put_settings(Req); + {<<"GET">>, <<"/v1/admin/automod/hits">>} -> list_hits(Req); + {<<"PUT">>, _} -> + case cowboy_req:binding(id, Req) of + undefined -> handler_utils:send_error(Req, 404, <<"Not found">>); + Id -> resolve_hit(Req, Id) + end; + _ -> + handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +trails() -> + [ + #{path => <<"/v1/admin/automod/settings">>, + method => <<"GET">>, + description => <<"Get automoderation settings">>, + tags => [<<"Automoderation">>], + responses => #{200 => #{description => <<"Settings">>}}}, + #{path => <<"/v1/admin/automod/settings">>, + method => <<"PUT">>, + description => <<"Update automoderation settings">>, + tags => [<<"Automoderation">>], + responses => #{200 => #{description => <<"Updated">>}, 403 => #{description => <<"Forbidden">>}}}, + #{path => <<"/v1/admin/automod/hits">>, + method => <<"GET">>, + description => <<"List automoderation hits">>, + tags => [<<"Automoderation">>], + responses => #{200 => #{description => <<"Hits">>}}}, + #{path => <<"/v1/admin/automod/hits/{id}">>, + method => <<"PUT">>, + description => <<"Approve or reject an automod hit">>, + tags => [<<"Automoderation">>], + responses => #{200 => #{description => <<"Resolved">>}, 404 => #{description => <<"Not found">>}}} + ]. + +get_settings(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + case admin_utils:check_role(AdminId, [superadmin, admin, moderator]) of + true -> + {ok, S} = logic_automoderation:get_settings(), + handler_utils:send_json(Req1, 200, logic_automoderation:settings_to_json(S)); + false -> + handler_utils:send_error(Req1, 403, <<"Access denied">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +put_settings(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Map when is_map(Map) -> + Updates = parse_settings(Map), + case logic_automoderation:update_settings(AdminId, Updates) of + {ok, S} -> + admin_utils:log_admin_action(AdminId, <<"update">>, <<"automod_settings">>, + <<"default">>, <<>>, Req2), + handler_utils:send_json(Req2, 200, logic_automoderation:settings_to_json(S)); + {error, access_denied} -> + handler_utils:send_error(Req2, 403, <<"Access denied">>); + {error, {invalid_field, _}} -> + handler_utils:send_error(Req2, 400, <<"Invalid settings">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +list_hits(Req) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + Qs = cowboy_req:parse_qs(Req1), + Filters = lists:filtermap(fun + ({<<"status">>, <<"open">>}) -> {true, {status, open}}; + ({<<"status">>, <<"approved">>}) -> {true, {status, approved}}; + ({<<"status">>, <<"rejected">>}) -> {true, {status, rejected}}; + ({<<"trigger">>, <<"keyword">>}) -> {true, {trigger, keyword}}; + ({<<"trigger">>, <<"report_threshold">>}) -> {true, {trigger, report_threshold}}; + (_) -> false + end, Qs), + case logic_automoderation:list_hits(AdminId, Filters) of + {ok, Hits} -> + Sorted = lists:reverse(lists:keysort(#automod_hit.created_at, Hits)), + handler_utils:send_json(Req1, 200, [logic_automoderation:hit_to_json(H) || H <- Sorted]); + {error, access_denied} -> + handler_utils:send_error(Req1, 403, <<"Access denied">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +resolve_hit(Req, Id) -> + case handler_utils:auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"status">> := StatusBin} -> + Decision = case StatusBin of + <<"approved">> -> approved; + <<"rejected">> -> rejected; + _ -> invalid + end, + case Decision of + invalid -> + handler_utils:send_error(Req2, 400, <<"status must be approved or rejected">>); + _ -> + case logic_automoderation:resolve_hit(AdminId, Id, Decision) of + {ok, Hit} -> + admin_utils:log_admin_action(AdminId, StatusBin, <<"automod_hit">>, Id, <<>>, Req2), + handler_utils:send_json(Req2, 200, logic_automoderation:hit_to_json(Hit)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Hit not found">>); + {error, already_resolved} -> + handler_utils:send_error(Req2, 409, <<"Already resolved">>); + {error, access_denied} -> + handler_utils:send_error(Req2, 403, <<"Access denied">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Missing status">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +parse_settings(Map) -> + lists:filtermap(fun + ({<<"keyword_action">>, <<"reject">>}) -> {true, {keyword_action, reject}}; + ({<<"keyword_action">>, <<"flag">>}) -> {true, {keyword_action, flag}}; + ({<<"keyword_action">>, <<"censor">>}) -> {true, {keyword_action, censor}}; + ({<<"match_mode">>, <<"word_boundary">>}) -> {true, {match_mode, word_boundary}}; + ({<<"match_mode">>, <<"substring">>}) -> {true, {match_mode, substring}}; + ({<<"report_threshold">>, V}) when is_integer(V) -> + {true, {report_threshold, V}}; + (_) -> false + end, maps:to_list(Map)). diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index c356e60..0432837 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -136,6 +136,8 @@ create_calendar(Req) -> handler_utils:send_json(Req2, 201, Response); {error, user_inactive} -> handler_utils:send_error(Req2, 403, <<"User account is not active">>); + {error, {content_banned, _Words}} -> + handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index 212fced..f5435a2 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -186,6 +186,8 @@ update_event(Req) -> handler_utils:send_error(Req2, 404, <<"Event not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, {content_banned, _}} -> + handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 7782272..5edbf6a 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -171,11 +171,12 @@ create_event(Req) -> case handler_utils:parse_datetime(StartTimeStr) of {ok, StartTime} -> Location = parse_location(maps:get(<<"location">>, Decoded, undefined)), - case maps:get(<<"recurrence">>, Decoded, undefined) of + Description = maps:get(<<"description">>, Decoded, <<>>), + case maps:get(<<"recurrence">>, Decoded, undefined) of undefined -> - case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of + case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of {ok, Event} -> - update_event_fields(Event#event.id, Location, Decoded), + update_event_fields(UserId, Event#event.id, Location, Decoded), {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), Response = handler_utils:event_to_json(UpdatedEvent), handler_utils:send_json(Req2, 201, Response); @@ -185,13 +186,15 @@ create_event(Req) -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, {content_banned, _}} -> + handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; RRule -> - case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of + case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of {ok, Event} -> - update_event_fields(Event#event.id, Location, Decoded), + update_event_fields(UserId, Event#event.id, Location, Decoded), {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), Response = handler_utils:event_to_json(UpdatedEvent), handler_utils:send_json(Req2, 201, Response); @@ -203,6 +206,8 @@ create_event(Req) -> handler_utils:send_error(Req2, 404, <<"Calendar not found">>); {error, event_in_past} -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); + {error, {content_banned, _}} -> + handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end @@ -257,14 +262,21 @@ list_events(Req) -> %%% Вспомогательные функции %%%=================================================================== -update_event_fields(EventId, Location, Decoded) -> +update_event_fields(UserId, EventId, Location, Decoded) -> Updates = [], Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end, Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of undefined -> Updates1; Cap -> [{capacity, Cap} | Updates1] end, Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end, - Updates4 = case maps:get(<<"description">>, Decoded, undefined) of undefined -> Updates3; Desc -> [{description, Desc} | Updates3] end, - Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates4; Link -> [{online_link, Link} | Updates4] end, - if Updates5 /= [] -> core_event:update(EventId, Updates5); true -> ok end. + %% description already applied in create_event/6 when present + Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end, + case Updates4 of + [] -> ok; + _ -> + case logic_event:update_event(UserId, EventId, Updates4) of + {ok, _} -> ok; + _ -> ok + end + end. parse_location(undefined) -> undefined; parse_location(LocationMap) when is_map(LocationMap) -> diff --git a/src/handlers/handler_reports.erl b/src/handlers/handler_reports.erl index a612299..3a0d820 100644 --- a/src/handlers/handler_reports.erl +++ b/src/handlers/handler_reports.erl @@ -33,7 +33,7 @@ trails() -> type => object, required => [<<"target_type">>, <<"target_id">>, <<"reason">>], properties => #{ - target_type => #{type => string, enum => [<<"event">>, <<"calendar">>]}, + target_type => #{type => string, enum => [<<"event">>, <<"calendar">>, <<"review">>]}, target_id => #{type => string}, reason => #{type => string} } @@ -106,14 +106,19 @@ create_report(Req) -> case Decoded of #{<<"target_type">> := TargetTypeBin, <<"target_id">> := TargetId, <<"reason">> := Reason} -> TargetType = parse_target_type(TargetTypeBin), - case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of - {ok, Report} -> - Response = handler_utils:report_to_json(Report), - handler_utils:send_json(Req2, 201, Response); - {error, target_not_found} -> - handler_utils:send_error(Req2, 404, <<"Target not found">>); - {error, _} -> - handler_utils:send_error(Req2, 500, <<"Internal server error">>) + case TargetType of + undefined -> + handler_utils:send_error(Req2, 400, <<"Invalid target_type">>); + _ -> + case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of + {ok, Report} -> + Response = handler_utils:report_to_json(Report), + handler_utils:send_json(Req2, 201, Response); + {error, target_not_found} -> + handler_utils:send_error(Req2, 404, <<"Target not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end end; _ -> handler_utils:send_error(Req2, 400, <<"Missing required fields">>) @@ -161,7 +166,7 @@ list_reports(Req) -> %%%=================================================================== %% @private Преобразует бинарное имя типа в атом. -%% Поддерживаются только 'event' и 'calendar'. +%% Поддерживаются 'event', 'calendar', 'review'. -spec parse_target_type(binary()) -> event | calendar | review | undefined. parse_target_type(<<"event">>) -> event; parse_target_type(<<"calendar">>) -> calendar; diff --git a/src/handlers/handler_reviews.erl b/src/handlers/handler_reviews.erl index 96711c7..1ec8b12 100644 --- a/src/handlers/handler_reviews.erl +++ b/src/handlers/handler_reviews.erl @@ -138,6 +138,8 @@ create_review(Req) -> handler_utils:send_error(Req2, 403, <<"Cannot review this target">>); {error, target_not_found} -> handler_utils:send_error(Req2, 404, <<"Target not found">>); + {error, {content_banned, _}} -> + handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index d32eb98..d311d31 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -17,7 +17,7 @@ calendar, calendar_share, calendar_specialist, event, recurrence_exception, booking, - review, report, banned_word, + review, report, banned_word, automod_settings, automod_hit, ticket, subscription, admin_audit, notification, stats, node_metric, schema_migration @@ -297,6 +297,8 @@ table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}]; table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}]; +table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}]; +table_opts(automod_hit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_hit)}]; table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields, ticket)}]; table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}]; table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; diff --git a/src/logic/logic_automoderation.erl b/src/logic/logic_automoderation.erl new file mode 100644 index 0000000..544255a --- /dev/null +++ b/src/logic/logic_automoderation.erl @@ -0,0 +1,352 @@ +%%%------------------------------------------------------------------- +%%% Автомодерация: сканирование бан-слов, политики, 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. diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index a6d2f1c..ed6320f 100644 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -20,16 +20,28 @@ create_calendar(UserId, Title, Description, Confirmation, Type) -> {ok, User} -> case User#user.status of active -> - case Type of - commercial -> - case logic_subscription:can_create_commercial_calendar(UserId) of - true -> - core_calendar:create(UserId, Title, Description, Confirmation, Type); - false -> - {error, subscription_required} - end; - personal -> - core_calendar:create(UserId, Title, Description, Confirmation, Type) + case logic_automoderation:evaluate_texts([Title, Description]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Title2, Desc2], Words} -> + Result = case Type of + commercial -> + case logic_subscription:can_create_commercial_calendar(UserId) of + true -> + core_calendar:create(UserId, Title2, Desc2, Confirmation, Type); + false -> + {error, subscription_required} + end; + personal -> + core_calendar:create(UserId, Title2, Desc2, Confirmation, Type) + end, + case Result of + {ok, Cal} -> + logic_automoderation:apply_after_save(calendar, Cal#calendar.id, Action, Words), + core_calendar:get_by_id(Cal#calendar.id); + Error -> + Error + end end; _ -> {error, user_inactive} @@ -61,7 +73,24 @@ update_calendar(UserId, CalendarId, Updates) -> case can_edit(UserId, Calendar) of true -> ValidUpdates = validate_updates(Updates), - core_calendar:update(CalendarId, ValidUpdates); + case content_fields(ValidUpdates, [title, description]) of + {[], []} -> + core_calendar:update(CalendarId, ValidUpdates); + {Fields, Texts} -> + case logic_automoderation:evaluate_texts(Texts) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, OutTexts, Words} -> + FinalUpdates = apply_text_results(ValidUpdates, Fields, OutTexts), + case core_calendar:update(CalendarId, FinalUpdates) of + {ok, _Updated} -> + logic_automoderation:apply_after_save(calendar, CalendarId, Action, Words), + core_calendar:get_by_id(CalendarId); + Error -> + Error + end + end + end; false -> {error, access_denied} end; @@ -69,6 +98,18 @@ update_calendar(UserId, CalendarId, Updates) -> Error end. +content_fields(Updates, Fields) -> + lists:foldl(fun(F, {Fs, Ts}) -> + case lists:keyfind(F, 1, Updates) of + {F, V} when is_binary(V) -> {Fs ++ [F], Ts ++ [V]}; + _ -> {Fs, Ts} + end + end, {[], []}, Fields). + +apply_text_results(Updates, [], []) -> Updates; +apply_text_results(Updates, [F | Fs], [T | Ts]) -> + apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts). + %% Удаление календаря delete_calendar(UserId, CalendarId) -> case core_calendar:get_by_id(CalendarId) of diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 327dbd1..f363436 100644 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -1,8 +1,8 @@ -module(logic_event). -include("records.hrl"). --export([create_event/5, create_recurring_event/6, get_event/2, list_events/2, - update_event/3, delete_event/2]). +-export([create_event/5, create_event/6, create_recurring_event/6, create_recurring_event/7, + get_event/2, list_events/2, update_event/3, delete_event/2]). -export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]). -export([materialize_for_booking/3]). -export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]). @@ -12,13 +12,31 @@ %% Создание одиночного события create_event(UserId, CalendarId, Title, StartTime, Duration) -> + create_event(UserId, CalendarId, Title, StartTime, Duration, <<>>). + +create_event(UserId, CalendarId, Title, StartTime, Duration, Description) -> case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> case validate_event_time(StartTime, UserId) of ok -> - core_event:create(CalendarId, Title, StartTime, Duration); + case logic_automoderation:evaluate_texts([Title, Description]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Title2, Desc2], Words} -> + case core_event:create(CalendarId, Title2, StartTime, Duration) of + {ok, Event} -> + case Desc2 of + <<>> -> ok; + _ -> _ = core_event:update(Event#event.id, [{description, Desc2}]) + end, + logic_automoderation:apply_after_save(event, Event#event.id, Action, Words), + core_event:get_by_id(Event#event.id); + Error -> + Error + end + end; {error, _} = Error -> Error end; @@ -31,6 +49,9 @@ create_event(UserId, CalendarId, Title, StartTime, Duration) -> %% Создание повторяющегося события create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> + create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, <<>>). + +create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) -> case logic_calendar:get_calendar(UserId, CalendarId) of {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of @@ -39,7 +60,22 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> ok -> case logic_recurrence:validate_rrule(RRule) of true -> - core_event:create_recurring(CalendarId, Title, StartTime, Duration, RRule); + case logic_automoderation:evaluate_texts([Title, Description]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Title2, Desc2], Words} -> + case core_event:create_recurring(CalendarId, Title2, StartTime, Duration, RRule) of + {ok, Event} -> + case Desc2 of + <<>> -> ok; + _ -> _ = core_event:update(Event#event.id, [{description, Desc2}]) + end, + logic_automoderation:apply_after_save(event, Event#event.id, Action, Words), + core_event:get_by_id(Event#event.id); + Error -> + Error + end + end; false -> {error, invalid_rrule} end; @@ -141,7 +177,34 @@ update_event(UserId, EventId, Updates) -> case logic_calendar:can_edit(UserId, Calendar) of true -> ValidUpdates = validate_updates(Updates, UserId), - core_event:update(EventId, ValidUpdates); + Title = proplists:get_value(title, ValidUpdates, Event#event.title), + Desc = proplists:get_value(description, ValidUpdates, Event#event.description), + case logic_automoderation:evaluate_texts([Title, Desc]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Title2, Desc2], Words} -> + Final0 = case lists:keymember(title, 1, ValidUpdates) of + true -> lists:keystore(title, 1, ValidUpdates, {title, Title2}); + false -> ValidUpdates + end, + Final = case lists:keymember(description, 1, Final0) orelse Desc2 =/= Event#event.description of + true when Action =:= censor -> + lists:keystore(description, 1, Final0, {description, Desc2}); + true -> + case lists:keymember(description, 1, Final0) of + true -> lists:keystore(description, 1, Final0, {description, Desc2}); + false -> Final0 + end; + false -> Final0 + end, + case core_event:update(EventId, Final) of + {ok, _} -> + logic_automoderation:apply_after_save(event, EventId, Action, Words), + core_event:get_by_id(EventId); + Error -> + Error + end + end; false -> {error, access_denied} end; diff --git a/src/logic/logic_moderation.erl b/src/logic/logic_moderation.erl index f7038be..97aae9f 100644 --- a/src/logic/logic_moderation.erl +++ b/src/logic/logic_moderation.erl @@ -18,8 +18,6 @@ freeze_event/2, unfreeze_event/2]). --define(REPORT_THRESHOLD, 3). - %% ============ Жалобы ===================================== create_report(ReporterId, TargetType, TargetId, Reason) -> @@ -74,27 +72,19 @@ resolve_report(AdminId, ReportId, Action) end. check_auto_freeze(TargetType, TargetId) -> - Count = core_report:get_count_by_target(TargetType, TargetId), + Settings = core_automod_settings:get(), + Threshold = Settings#automod_settings.report_threshold, + Count = pending_count(TargetType, TargetId), if - Count >= ?REPORT_THRESHOLD -> - auto_freeze(TargetType, TargetId); + Count >= Threshold -> + logic_automoderation:record_report_threshold_hit(TargetType, TargetId); true -> ok end. -auto_freeze(event, EventId) -> - case core_event:get_by_id(EventId) of - {ok, Event} when Event#event.status =:= active -> - core_event:update(EventId, [{status, frozen}]); - _ -> ok - end; -auto_freeze(calendar, CalendarId) -> - case core_calendar:get_by_id(CalendarId) of - {ok, Calendar} when Calendar#calendar.status =:= active -> - core_calendar:update(CalendarId, [{status, frozen}]); - _ -> ok - end; -auto_freeze(_, _) -> ok. +pending_count(TargetType, TargetId) -> + length([R || R <- core_report:list_by_target(TargetType, TargetId), + R#report.status =:= pending]). %% ============ Бан-лист =================================== @@ -119,26 +109,13 @@ list_banned_words(AdminId) -> %% ============ Контент-фильтр ============================= check_content(Text) -> - Words = core_banned_words:list_banned_words(), - LowerText = string:lowercase(binary_to_list(Text)), - lists:any(fun(W) -> - string:str(LowerText, binary_to_list(W#banned_word.word)) > 0 - end, Words). + logic_automoderation:scan_text(Text) =/= clean. auto_moderate(Text) -> - Words = core_banned_words:list_banned_words(), - lists:foldl(fun(W, Acc) -> - WordStr = binary_to_list(W#banned_word.word), - LowerAccStr = string:lowercase(binary_to_list(Acc)), - case string:str(LowerAccStr, WordStr) of - 0 -> Acc; - Pos -> - Len = length(WordStr), - Start = binary:part(Acc, {0, Pos-1}), - Rest = binary:part(Acc, {Pos-1+Len, byte_size(Acc)-Pos+1-Len}), - <> - end - end, Text, Words). + case logic_automoderation:scan_text(Text) of + clean -> Text; + {hit, Words} -> logic_automoderation:censor_text(Text, Words) + end. %% ============ Заморозка/разморозка ======================= @@ -198,4 +175,9 @@ target_exists(calendar, CalendarId) -> {ok, _} -> true; _ -> false end; -target_exists(_, _) -> false. \ No newline at end of file +target_exists(review, ReviewId) -> + case core_review:get_by_id(ReviewId) of + {ok, _} -> true; + _ -> false + end; +target_exists(_, _) -> false. diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl index 211f634..3e12cae 100644 --- a/src/logic/logic_review.erl +++ b/src/logic/logic_review.erl @@ -15,12 +15,18 @@ create_review(UserId, TargetType, TargetId, Rating, Comment) -> {ok, true} -> case core_review:has_user_reviewed(UserId, TargetType, TargetId) of false -> - case core_review:create(UserId, TargetType, TargetId, Rating, Comment) of - {ok, Review} -> - update_target_rating(TargetType, TargetId), - {ok, Review}; - Error -> - Error + case logic_automoderation:evaluate_texts([Comment]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Comment2], Words} -> + case core_review:create(UserId, TargetType, TargetId, Rating, Comment2) of + {ok, Review} -> + update_target_rating(TargetType, TargetId), + logic_automoderation:apply_after_save(review, Review#review.id, Action, Words), + core_review:get_by_id(Review#review.id); + Error -> + Error + end end; true -> {error, already_reviewed} @@ -81,11 +87,22 @@ update_review(UserId, ReviewId, Updates) -> case ValidUpdates of [] -> {error, no_valid_updates}; _ -> - case core_review:update(ReviewId, ValidUpdates) of - {ok, Updated} -> - update_target_rating(Review#review.target_type, Review#review.target_id), - {ok, Updated}; - Error -> Error + Comment = proplists:get_value(comment, ValidUpdates, Review#review.comment), + case logic_automoderation:evaluate_texts([Comment]) of + {reject, Words} -> + {error, {content_banned, Words}}; + {ok, Action, [Comment2], Words} -> + Final = case lists:keymember(comment, 1, ValidUpdates) of + true -> lists:keystore(comment, 1, ValidUpdates, {comment, Comment2}); + false -> ValidUpdates + end, + case core_review:update(ReviewId, Final) of + {ok, _Updated} -> + update_target_rating(Review#review.target_type, Review#review.target_id), + logic_automoderation:apply_after_save(review, ReviewId, Action, Words), + core_review:get_by_id(ReviewId); + Error -> Error + end end end; false -> {error, access_denied} diff --git a/src/migrations/20260717150000_automod_tables.erl b/src/migrations/20260717150000_automod_tables.erl new file mode 100644 index 0000000..38ee82d --- /dev/null +++ b/src/migrations/20260717150000_automod_tables.erl @@ -0,0 +1,29 @@ +%% @doc Create automod_settings and automod_hit tables if missing. +-module('20260717150000_automod_tables'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_table(automod_settings, record_info(fields, automod_settings)), + ensure_table(automod_hit, record_info(fields, automod_hit)), + core_automod_settings:ensure_defaults(), + ok. + +down() -> + _ = mnesia:delete_table(automod_hit), + _ = mnesia:delete_table(automod_settings), + ok. + +ensure_table(Table, Attrs) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> + ok; + false -> + case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table}} -> ok; + {aborted, Reason} -> error({create_table_failed, Table, Reason}) + end + end. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 8605e8b..3775225 100644 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -37,6 +37,8 @@ admin() -> admin_handler_reviews_by_id, % ================== БАН-СЛОВА ================== admin_handler_banned_words, + % ================== АВТОМОДЕРАЦИЯ ================== + admin_handler_automod, % ================== ТИКЕТЫ ================== admin_handler_ticket_stats, admin_handler_ticket_by_id, diff --git a/test/api/admins/admin_automod_tests.erl b/test/api/admins/admin_automod_tests.erl new file mode 100644 index 0000000..3f92279 --- /dev/null +++ b/test/api/admins/admin_automod_tests.erl @@ -0,0 +1,140 @@ +%%%------------------------------------------------------------------- +%%% @doc API-тесты автомодерации (admin settings + hits queue). +%%%------------------------------------------------------------------- +-module(admin_automod_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== Admin Automod Tests ==="), + Admin = api_test_runner:get_admin_token(), + Moder = api_test_runner:get_moderator_token(), + + test_get_settings(Admin), + test_put_settings(Admin), + test_put_settings_forbidden_moderator(Moder), + Unique = integer_to_binary(erlang:system_time()), + BadWord = <<"am_spam_", Unique/binary>>, + test_seed_banned_word(Admin, BadWord), + test_flag_creates_hit(Admin, BadWord), + test_list_hits_filter(Admin), + test_approve_restores(Admin, Moder, BadWord), + test_reject_keeps_frozen(Admin, BadWord), + test_restore_defaults(Admin), + + ct:pal("=== All admin automod tests passed ==="), + ok. + +test_get_settings(Token) -> + ct:pal(" TEST: GET automod settings"), + S = api_test_runner:admin_get(<<"/v1/admin/automod/settings">>, Token), + ?assert(maps:is_key(<<"keyword_action">>, S)), + ?assert(maps:is_key(<<"match_mode">>, S)), + ?assert(maps:is_key(<<"report_threshold">>, S)), + ct:pal(" OK"). + +test_put_settings(Token) -> + ct:pal(" TEST: PUT automod settings"), + S = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Token, #{ + <<"keyword_action">> => <<"flag">>, + <<"match_mode">> => <<"word_boundary">>, + <<"report_threshold">> => 3 + }), + ?assertEqual(<<"flag">>, maps:get(<<"keyword_action">>, S)), + ?assertEqual(3, maps:get(<<"report_threshold">>, S)), + ct:pal(" OK"). + +test_put_settings_forbidden_moderator(Token) -> + ct:pal(" TEST: moderator cannot PUT settings"), + {ok, Status, _, _} = api_test_runner:admin_request(put, + <<"/v1/admin/automod/settings">>, Token, + jsx:encode(#{<<"report_threshold">> => 9})), + ?assertEqual(403, Status), + ct:pal(" OK"). + +test_seed_banned_word(Token, Word) -> + ct:pal(" TEST: seed banned word ~s", [Word]), + _ = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word}), + ct:pal(" OK"). + +test_flag_creates_hit(AdminToken, BadWord) -> + ct:pal(" TEST: flag policy creates hit via user content"), + api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, AdminToken, #{ + <<"keyword_action">> => <<"flag">>, + <<"match_mode">> => <<"word_boundary">>, + <<"report_threshold">> => 3 + }), + User = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(User, #{title => <<"AutomodCal">>}), + Title = <<"Party with ", BadWord/binary>>, + {ok, 201, _, Body} = api_test_runner:client_request(post, + <<"/v1/calendars/", CalId/binary, "/events">>, User, + jsx:encode(#{ + title => Title, + start_time => api_test_runner:future_date_iso8601(), + duration => 60 + })), + #{<<"id">> := EventId, <<"status">> := Status} = + jsx:decode(list_to_binary(Body), [return_maps]), + ?assertEqual(<<"frozen">>, Status), + Hits = api_test_runner:admin_get(<<"/v1/admin/automod/hits?status=open">>, AdminToken), + ?assert(lists:any(fun(H) -> + maps:get(<<"entity_id">>, H) =:= EventId andalso + maps:get(<<"trigger">>, H) =:= <<"keyword">> + end, Hits)), + put({automod_event, BadWord}, EventId), + ct:pal(" OK: event ~s frozen + hit", [EventId]). + +test_list_hits_filter(Token) -> + ct:pal(" TEST: list hits filter by trigger"), + Hits = api_test_runner:admin_get(<<"/v1/admin/automod/hits?trigger=keyword">>, Token), + ?assert(is_list(Hits)), + ?assert(lists:all(fun(H) -> maps:get(<<"trigger">>, H) =:= <<"keyword">> end, Hits)), + ct:pal(" OK: ~p", [length(Hits)]). + +test_approve_restores(AdminToken, ModerToken, BadWord) -> + ct:pal(" TEST: approve hit restores entity"), + EventId = get({automod_event, BadWord}), + Hits = api_test_runner:admin_get(<<"/v1/admin/automod/hits?status=open">>, AdminToken), + Hit = hd([H || H <- Hits, maps:get(<<"entity_id">>, H) =:= EventId]), + HitId = maps:get(<<"id">>, Hit), + Resolved = api_test_runner:admin_put( + <<"/v1/admin/automod/hits/", HitId/binary>>, ModerToken, + #{<<"status">> => <<"approved">>}), + ?assertEqual(<<"approved">>, maps:get(<<"status">>, Resolved)), + %% admin event fetch + Ev = api_test_runner:admin_get(<<"/v1/admin/events/", EventId/binary>>, AdminToken), + ?assertEqual(<<"active">>, maps:get(<<"status">>, Ev)), + ct:pal(" OK"). + +test_reject_keeps_frozen(AdminToken, BadWord) -> + ct:pal(" TEST: reject hit keeps freeze"), + User = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(User, #{title => <<"AutomodCal2">>}), + Title = <<"Again ", BadWord/binary>>, + {ok, 201, _, Body} = api_test_runner:client_request(post, + <<"/v1/calendars/", CalId/binary, "/events">>, User, + jsx:encode(#{ + title => Title, + start_time => api_test_runner:future_date_iso8601(), + duration => 45 + })), + #{<<"id">> := EventId} = jsx:decode(list_to_binary(Body), [return_maps]), + Hits = api_test_runner:admin_get(<<"/v1/admin/automod/hits?status=open">>, AdminToken), + Hit = hd([H || H <- Hits, maps:get(<<"entity_id">>, H) =:= EventId]), + HitId = maps:get(<<"id">>, Hit), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/hits/", HitId/binary>>, AdminToken, + #{<<"status">> => <<"rejected">>}), + Ev = api_test_runner:admin_get(<<"/v1/admin/events/", EventId/binary>>, AdminToken), + ?assertEqual(<<"frozen">>, maps:get(<<"status">>, Ev)), + ct:pal(" OK"). + +test_restore_defaults(Token) -> + ct:pal(" TEST: restore default settings"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Token, #{ + <<"keyword_action">> => <<"flag">>, + <<"match_mode">> => <<"word_boundary">>, + <<"report_threshold">> => 3 + }), + ct:pal(" OK"). diff --git a/test/api/users/user_automod_tests.erl b/test/api/users/user_automod_tests.erl new file mode 100644 index 0000000..769524e --- /dev/null +++ b/test/api/users/user_automod_tests.erl @@ -0,0 +1,152 @@ +%%%------------------------------------------------------------------- +%%% @doc User-facing automod API: content reject + report threshold + review reports. +%%%------------------------------------------------------------------- +-module(user_automod_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== User Automod Tests ==="), + Admin = api_test_runner:get_admin_token(), + Unique = integer_to_binary(erlang:system_time()), + BadWord = <<"uam_bad_", Unique/binary>>, + _ = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Admin, #{<<"word">> => BadWord}), + + test_reject_event_create(Admin, BadWord), + test_reject_calendar_create(Admin, BadWord), + test_report_threshold_freezes_event(Admin), + test_report_on_review(Admin), + test_dismissed_reports_do_not_freeze(Admin), + + %% restore defaults + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"keyword_action">> => <<"flag">>, + <<"match_mode">> => <<"word_boundary">>, + <<"report_threshold">> => 3 + }), + ct:pal("=== All user automod tests passed ==="), + ok. + +test_reject_event_create(Admin, BadWord) -> + ct:pal(" TEST: reject policy returns 400 on event create"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"keyword_action">> => <<"reject">> + }), + User = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(User, #{title => <<"CleanCal">>}), + {ok, Status, _, Body} = api_test_runner:client_request(post, + <<"/v1/calendars/", CalId/binary, "/events">>, User, + jsx:encode(#{ + title => <<"Has ", BadWord/binary>>, + start_time => api_test_runner:future_date_iso8601(), + duration => 30 + })), + ?assertEqual(400, Status), + ?assert(binary:match(list_to_binary(Body), <<"banned">>) =/= nomatch), + ct:pal(" OK"). + +test_reject_calendar_create(Admin, BadWord) -> + ct:pal(" TEST: reject policy returns 400 on calendar create"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"keyword_action">> => <<"reject">> + }), + User = api_test_runner:get_user_token(), + {ok, Status, _, _} = api_test_runner:client_request(post, <<"/v1/calendars">>, User, + jsx:encode(#{title => <<"Cal ", BadWord/binary>>, description => <<"x">>})), + ?assertEqual(400, Status), + ct:pal(" OK"). + +test_report_threshold_freezes_event(Admin) -> + ct:pal(" TEST: 3 pending reports freeze event"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"keyword_action">> => <<"flag">>, + <<"report_threshold">> => 3 + }), + Owner = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(Owner, #{title => <<"RepThrCal">>}), + #{<<"id">> := EventId} = api_test_runner:client_post( + <<"/v1/calendars/", CalId/binary, "/events">>, Owner, + #{title => <<"Clean event">>, + start_time => api_test_runner:future_date_iso8601(), + duration => 60}), + lists:foreach(fun(_) -> + Tok = api_test_runner:get_user_token(), + {ok, 201, _, _} = api_test_runner:client_request(post, <<"/v1/reports">>, Tok, + jsx:encode(#{ + target_type => <<"event">>, + target_id => EventId, + reason => <<"spam">> + })) + end, [1, 2, 3]), + Ev = api_test_runner:admin_get(<<"/v1/admin/events/", EventId/binary>>, Admin), + ?assertEqual(<<"frozen">>, maps:get(<<"status">>, Ev)), + Hits = api_test_runner:admin_get(<<"/v1/admin/automod/hits?trigger=report_threshold">>, Admin), + ?assert(lists:any(fun(H) -> maps:get(<<"entity_id">>, H) =:= EventId end, Hits)), + ct:pal(" OK"). + +test_report_on_review(Admin) -> + ct:pal(" TEST: report on review + threshold hides"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"report_threshold">> => 3 + }), + Owner = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(Owner, #{title => <<"RevRepCal">>}), + Reviewer = api_test_runner:get_user_token(), + {ok, 201, _, RevBody} = api_test_runner:client_request(post, <<"/v1/reviews">>, Reviewer, + jsx:encode(#{ + target_type => <<"calendar">>, + target_id => CalId, + rating => 2, + comment => <<"meh">> + })), + #{<<"id">> := ReviewId} = jsx:decode(list_to_binary(RevBody), [return_maps]), + lists:foreach(fun(_) -> + Tok = api_test_runner:get_user_token(), + {ok, 201, _, _} = api_test_runner:client_request(post, <<"/v1/reports">>, Tok, + jsx:encode(#{ + target_type => <<"review">>, + target_id => ReviewId, + reason => <<"abuse">> + })) + end, [1, 2, 3]), + Rev = api_test_runner:admin_get(<<"/v1/admin/reviews/", ReviewId/binary>>, Admin), + ?assertEqual(<<"hidden">>, maps:get(<<"status">>, Rev)), + ct:pal(" OK"). + +test_dismissed_reports_do_not_freeze(Admin) -> + ct:pal(" TEST: dismissed reports do not freeze"), + _ = api_test_runner:admin_put(<<"/v1/admin/automod/settings">>, Admin, #{ + <<"report_threshold">> => 3 + }), + Owner = api_test_runner:get_user_token(), + CalId = api_test_runner:create_calendar(Owner, #{title => <<"DismissCal">>}), + #{<<"id">> := EventId} = api_test_runner:client_post( + <<"/v1/calendars/", CalId/binary, "/events">>, Owner, + #{title => <<"Still clean">>, + start_time => api_test_runner:future_date_iso8601(), + duration => 60}), + ReportIds = lists:map(fun(_) -> + Tok = api_test_runner:get_user_token(), + {ok, 201, _, B} = api_test_runner:client_request(post, <<"/v1/reports">>, Tok, + jsx:encode(#{ + target_type => <<"event">>, + target_id => EventId, + reason => <<"noise">> + })), + maps:get(<<"id">>, jsx:decode(list_to_binary(B), [return_maps])) + end, [1, 2]), + lists:foreach(fun(Rid) -> + _ = api_test_runner:admin_put(<<"/v1/admin/reports/", Rid/binary>>, Admin, + #{<<"status">> => <<"dismissed">>}) + end, ReportIds), + Tok3 = api_test_runner:get_user_token(), + {ok, 201, _, _} = api_test_runner:client_request(post, <<"/v1/reports">>, Tok3, + jsx:encode(#{ + target_type => <<"event">>, + target_id => EventId, + reason => <<"one pending">> + })), + Ev = api_test_runner:admin_get(<<"/v1/admin/events/", EventId/binary>>, Admin), + ?assertEqual(<<"active">>, maps:get(<<"status">>, Ev)), + ct:pal(" OK"). diff --git a/test/api_admins_SUITE.erl b/test/api_admins_SUITE.erl index e284ecf..73f1903 100644 --- a/test/api_admins_SUITE.erl +++ b/test/api_admins_SUITE.erl @@ -33,6 +33,7 @@ all() -> admin_test_reports, admin_test_subscriptions, admin_test_banned_words, + admin_test_automod, admin_test_moderation, admin_test_audit, admin_test_stats, @@ -111,6 +112,9 @@ admin_test_subscriptions(_Config) -> admin_test_banned_words(_Config) -> admin_banned_words_tests:test(). +admin_test_automod(_Config) -> + admin_automod_tests:test(). + admin_test_moderation(_Config) -> admin_moderation_tests:test(). diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index 5dcbc0d..49503a0 100644 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -43,6 +43,7 @@ all() -> user_test_search, user_test_refresh, user_test_reports, + user_test_automod, user_test_tickets, user_test_subscription, user_test_websocket @@ -147,6 +148,9 @@ user_test_refresh(_Config) -> user_test_reports(_Config) -> user_reports_tests:test(). +user_test_automod(_Config) -> + user_automod_tests:test(). + user_test_tickets(_Config) -> user_tickets_tests:test(). diff --git a/test/unit/admin_handler_automod_tests.erl b/test/unit/admin_handler_automod_tests.erl new file mode 100644 index 0000000..dc7cb26 --- /dev/null +++ b/test/unit/admin_handler_automod_tests.erl @@ -0,0 +1,188 @@ +-module(admin_handler_automod_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [admin, admin_audit, automod_settings, automod_hit, banned_word, + user, calendar, event]). +-define(ADMIN_ID, <<"adm_automod">>). +-define(MOD_ID, <<"mod_automod">>). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + eh_test_support:ensure_jwt(), + eh_test_support:seed_admin(#{id => ?ADMIN_ID, role => admin}), + eh_test_support:seed_admin(#{id => ?MOD_ID, role => moderator, email => <<"m@t.local">>}), + core_automod_settings:ensure_defaults(), + ok. + +cleanup(_) -> + eh_test_support:unload_cowboy(), + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +admin_handler_automod_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"GET settings – 200", fun test_get_settings/0}, + {"GET settings – 401", fun test_get_settings_unauth/0}, + {"PUT settings – admin ok", fun test_put_settings/0}, + {"PUT settings – moderator forbidden", fun test_put_settings_moderator/0}, + {"PUT settings – invalid threshold", fun test_put_settings_bad/0}, + {"GET hits – list and filter", fun test_list_hits/0}, + {"PUT hit approve", fun test_approve_hit/0}, + {"PUT hit reject", fun test_reject_hit/0}, + {"PUT hit not found", fun test_hit_not_found/0}, + {"PUT hit already resolved", fun test_hit_already_resolved/0}, + {"moderator can list and resolve hits", fun test_moderator_hits/0} + ]}. + +test_get_settings() -> + {Status, _, Body} = eh_test_support:call(admin_handler_automod, #{ + method => <<"GET">>, + path => <<"/v1/admin/automod/settings">>, + auth => ?ADMIN_ID + }), + ?assertEqual(200, Status), + Map = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"flag">>, maps:get(<<"keyword_action">>, Map)), + ?assertEqual(<<"word_boundary">>, maps:get(<<"match_mode">>, Map)), + ?assertEqual(3, maps:get(<<"report_threshold">>, Map)). + +test_get_settings_unauth() -> + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"GET">>, + path => <<"/v1/admin/automod/settings">>, + auth => none + }), + ?assertEqual(401, Status). + +test_put_settings() -> + {Status, _, Body} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/settings">>, + auth => ?ADMIN_ID, + body => jsx:encode(#{ + <<"keyword_action">> => <<"reject">>, + <<"match_mode">> => <<"substring">>, + <<"report_threshold">> => 7 + }) + }), + ?assertEqual(200, Status), + Map = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"reject">>, maps:get(<<"keyword_action">>, Map)), + ?assertEqual(<<"substring">>, maps:get(<<"match_mode">>, Map)), + ?assertEqual(7, maps:get(<<"report_threshold">>, Map)). + +test_put_settings_moderator() -> + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/settings">>, + auth => ?MOD_ID, + body => jsx:encode(#{<<"report_threshold">> => 2}) + }), + ?assertEqual(403, Status). + +test_put_settings_bad() -> + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/settings">>, + auth => ?ADMIN_ID, + body => jsx:encode(#{<<"report_threshold">> => 0}) + }), + ?assertEqual(400, Status). + +test_list_hits() -> + {ok, H1} = core_automod_hit:create(event, <<"e1">>, keyword, [<<"a">>], flag), + {ok, _} = core_automod_hit:create(calendar, <<"c1">>, report_threshold, [], freeze), + {ok, _} = core_automod_hit:resolve(H1#automod_hit.id, approved, ?ADMIN_ID), + {Status, _, Body} = eh_test_support:call(admin_handler_automod, #{ + method => <<"GET">>, + path => <<"/v1/admin/automod/hits">>, + auth => ?ADMIN_ID, + qs => [{<<"status">>, <<"open">>}] + }), + ?assertEqual(200, Status), + List = jsx:decode(Body, [return_maps]), + ?assertEqual(1, length(List)), + ?assertEqual(<<"open">>, maps:get(<<"status">>, hd(List))). + +test_approve_hit() -> + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"C">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"t">>, eh_test_support:future_start(), 60), + _ = core_event:freeze(Ev#event.id, <<"auto:keyword">>), + {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [<<"x">>], flag), + {Status, _, Body} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/hits/", (Hit#automod_hit.id)/binary>>, + auth => ?ADMIN_ID, + bindings => #{id => Hit#automod_hit.id}, + body => jsx:encode(#{<<"status">> => <<"approved">>}) + }), + ?assertEqual(200, Status), + Map = jsx:decode(Body, [return_maps]), + ?assertEqual(<<"approved">>, maps:get(<<"status">>, Map)), + {ok, Active} = core_event:get_by_id(Ev#event.id), + ?assertEqual(active, Active#event.status). + +test_reject_hit() -> + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"C">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"t">>, eh_test_support:future_start(), 60), + {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [<<"x">>], flag), + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/hits/", (Hit#automod_hit.id)/binary>>, + auth => ?ADMIN_ID, + bindings => #{id => Hit#automod_hit.id}, + body => jsx:encode(#{<<"status">> => <<"rejected">>}) + }), + ?assertEqual(200, Status), + {ok, Frozen} = core_event:get_by_id(Ev#event.id), + ?assertEqual(frozen, Frozen#event.status). + +test_hit_not_found() -> + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/hits/missing">>, + auth => ?ADMIN_ID, + bindings => #{id => <<"missing">>}, + body => jsx:encode(#{<<"status">> => <<"approved">>}) + }), + ?assertEqual(404, Status). + +test_hit_already_resolved() -> + {ok, Hit} = core_automod_hit:create(event, <<"e">>, keyword, [], flag), + {ok, _} = core_automod_hit:resolve(Hit#automod_hit.id, approved, ?ADMIN_ID), + {Status, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/hits/", (Hit#automod_hit.id)/binary>>, + auth => ?ADMIN_ID, + bindings => #{id => Hit#automod_hit.id}, + body => jsx:encode(#{<<"status">> => <<"rejected">>}) + }), + ?assertEqual(409, Status). + +test_moderator_hits() -> + {ok, Hit} = core_automod_hit:create(event, <<"e2">>, keyword, [<<"z">>], flag), + {StatusList, _, _} = eh_test_support:call(admin_handler_automod, #{ + method => <<"GET">>, + path => <<"/v1/admin/automod/hits">>, + auth => ?MOD_ID + }), + ?assertEqual(200, StatusList), + {StatusPut, _, Body} = eh_test_support:call(admin_handler_automod, #{ + method => <<"PUT">>, + path => <<"/v1/admin/automod/hits/", (Hit#automod_hit.id)/binary>>, + auth => ?MOD_ID, + bindings => #{id => Hit#automod_hit.id}, + body => jsx:encode(#{<<"status">> => <<"approved">>}) + }), + ?assertEqual(200, StatusPut), + ?assertEqual(<<"approved">>, maps:get(<<"status">>, jsx:decode(Body, [return_maps]))). + +seed_user() -> + Id = base64:encode(crypto:strong_rand_bytes(10), #{mode => urlsafe, padding => false}), + mnesia:dirty_write(eh_test_support:make_user(#{id => Id, email => <>})), + Id. diff --git a/test/unit/core_automod_tests.erl b/test/unit/core_automod_tests.erl new file mode 100644 index 0000000..cd8142c --- /dev/null +++ b/test/unit/core_automod_tests.erl @@ -0,0 +1,64 @@ +-module(core_automod_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [automod_settings, automod_hit]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + ok. + +cleanup(_) -> + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +core_automod_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"settings defaults", fun test_defaults/0}, + {"settings update fields", fun test_update/0}, + {"hit create list resolve", fun test_hits/0}, + {"hit filters", fun test_hit_filters/0} + ]}. + +test_defaults() -> + core_automod_settings:ensure_defaults(), + S = core_automod_settings:get(), + ?assertEqual(flag, S#automod_settings.keyword_action), + ?assertEqual(word_boundary, S#automod_settings.match_mode), + ?assertEqual(3, S#automod_settings.report_threshold). + +test_update() -> + core_automod_settings:ensure_defaults(), + {ok, S} = core_automod_settings:update([ + {keyword_action, censor}, + {match_mode, substring}, + {report_threshold, 4} + ], <<"adm">>), + ?assertEqual(censor, S#automod_settings.keyword_action), + ?assertEqual(substring, S#automod_settings.match_mode), + ?assertEqual(4, S#automod_settings.report_threshold), + ?assertEqual(<<"adm">>, S#automod_settings.updated_by). + +test_hits() -> + {ok, H} = core_automod_hit:create(event, <<"eid">>, keyword, [<<"w">>], flag), + ?assertEqual(open, H#automod_hit.status), + {ok, Got} = core_automod_hit:get_by_id(H#automod_hit.id), + ?assertEqual(<<"eid">>, Got#automod_hit.entity_id), + {ok, Done} = core_automod_hit:resolve(H#automod_hit.id, approved, <<"adm">>), + ?assertEqual(approved, Done#automod_hit.status), + ?assertEqual(<<"adm">>, Done#automod_hit.resolved_by), + ?assertMatch({error, already_resolved}, + core_automod_hit:resolve(H#automod_hit.id, rejected, <<"adm">>)). + +test_hit_filters() -> + {ok, _} = core_automod_hit:create(event, <<"e1">>, keyword, [], flag), + {ok, H2} = core_automod_hit:create(review, <<"r1">>, report_threshold, [], hide), + {ok, _} = core_automod_hit:resolve(H2#automod_hit.id, rejected, <<"a">>), + Open = core_automod_hit:list([{status, open}]), + ?assertEqual(1, length(Open)), + Reviews = core_automod_hit:list([{entity_type, review}]), + ?assertEqual(1, length(Reviews)), + Thr = core_automod_hit:list([{trigger, report_threshold}]), + ?assertEqual(1, length(Thr)). diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index c6dccb7..aa70283 100644 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -121,6 +121,10 @@ table_opts(report) -> [{ram_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(banned_word) -> [{ram_copies, [node()]}, {attributes, record_info(fields, banned_word)}]; +table_opts(automod_settings) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, automod_settings)}]; +table_opts(automod_hit) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, automod_hit)}]; table_opts(ticket) -> [{ram_copies, [node()]}, {attributes, record_info(fields, ticket)}]; table_opts(subscription) -> diff --git a/test/unit/logic_automoderation_tests.erl b/test/unit/logic_automoderation_tests.erl new file mode 100644 index 0000000..4f30c72 --- /dev/null +++ b/test/unit/logic_automoderation_tests.erl @@ -0,0 +1,270 @@ +-module(logic_automoderation_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [admin, admin_audit, banned_word, automod_settings, automod_hit, + user, calendar, event, review, report]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + core_automod_settings:ensure_defaults(), + ok. + +cleanup(_) -> + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +logic_automoderation_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"word_boundary does not match substring inside word", fun test_word_boundary/0}, + {"substring mode matches inside word", fun test_substring/0}, + {"case insensitive scan", fun test_case_insensitive/0}, + {"punctuation word_boundary", fun test_punctuation_boundary/0}, + {"multiple banned words collected", fun test_multi_words/0}, + {"reject policy blocks save path via evaluate", fun test_reject/0}, + {"flag policy soft-hides event", fun test_flag_event/0}, + {"flag policy soft-hides calendar", fun test_flag_calendar/0}, + {"flag policy hides review", fun test_flag_review/0}, + {"censor policy masks words", fun test_censor/0}, + {"settings update", fun test_settings/0}, + {"settings update denied for moderator", fun test_settings_denied/0}, + {"invalid settings field", fun test_settings_invalid/0}, + {"approve hit restores event", fun test_approve_hit/0}, + {"reject hit keeps freeze", fun test_reject_hit/0}, + {"resolve already resolved", fun test_resolve_twice/0}, + {"report threshold creates hit and freezes", fun test_report_threshold/0}, + {"dismissed reports do not count toward threshold", fun test_dismissed_not_counted/0}, + {"custom threshold from settings", fun test_custom_threshold/0}, + {"report on review hides", fun test_report_review/0}, + {"logic create calendar reject", fun test_logic_calendar_reject/0}, + {"logic create event flag", fun test_logic_event_flag/0}, + {"logic create review censor", fun test_logic_review_censor/0} + ]}. + +admin_id() -> + (eh_test_support:seed_admin(#{id => <<"adm_am">>}))#admin.id. + +moderator_id() -> + (eh_test_support:seed_admin(#{id => <<"mod_am">>, role => moderator, + email => <<"mod@test.local">>}))#admin.id. + +add_word(Word) -> + {ok, _} = core_banned_words:add_banned_word(Word, admin_id()). + +seed_user() -> + Id = base64:encode(crypto:strong_rand_bytes(12), #{mode => urlsafe, padding => false}), + User = eh_test_support:make_user(#{id => Id, email => <>, status => active}), + mnesia:dirty_write(User), + Id. + +test_word_boundary() -> + add_word(<<"bad">>), + ?assertEqual(clean, logic_automoderation:scan_text(<<"badge">>)), + ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"this is bad">>)). + +test_substring() -> + add_word(<<"bad">>), + {ok, _} = core_automod_settings:update([{match_mode, substring}], admin_id()), + ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"badge">>)). + +test_case_insensitive() -> + add_word(<<"spam">>), + ?assertMatch({hit, [<<"spam">>]}, logic_automoderation:scan_text(<<"Buy SPAM now">>)). + +test_punctuation_boundary() -> + add_word(<<"bad">>), + ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"really bad!">>)), + ?assertMatch({hit, [<<"bad">>]}, logic_automoderation:scan_text(<<"bad,">>)), + ?assertEqual(clean, logic_automoderation:scan_text(<<"badger">>)). + +test_multi_words() -> + add_word(<<"foo">>), + add_word(<<"bar">>), + ?assertMatch({hit, Words} when length(Words) =:= 2, + logic_automoderation:scan_text(<<"foo and bar">>)). + +test_reject() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, reject}], admin_id()), + ?assertEqual({reject, [<<"spam">>]}, logic_automoderation:evaluate_texts([<<"buy spam now">>])). + +test_flag_event() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Action, [Title2], Words} = logic_automoderation:evaluate_texts([<<"spam event">>]), + ?assertEqual(flag, Action), + {ok, Ev} = core_event:create(Cal#calendar.id, Title2, eh_test_support:future_start(), 60), + ok = logic_automoderation:apply_after_save(event, Ev#event.id, Action, Words), + {ok, Frozen} = core_event:get_by_id(Ev#event.id), + ?assertEqual(frozen, Frozen#event.status), + Hits = core_automod_hit:list([{status, open}]), + ?assertEqual(1, length(Hits)). + +test_flag_calendar() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), + Owner = seed_user(), + {ok, Cal} = logic_calendar:create_calendar(Owner, <<"spam cal">>, <<"ok">>, manual), + {ok, Frozen} = core_calendar:get_by_id(Cal#calendar.id), + ?assertEqual(frozen, Frozen#calendar.status), + ?assertEqual(1, length(core_automod_hit:list([{entity_type, calendar}]))). + +test_flag_review() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), + Owner = seed_user(), + Reviewer = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + %% bypass can_review: create via core then apply_after_save path via logic + {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), + {ok, Action, [C], Words} = logic_automoderation:evaluate_texts([<<"spam comment">>]), + {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 4, C), + ok = logic_automoderation:apply_after_save(review, Rev#review.id, Action, Words), + {ok, Hidden} = core_review:get_by_id(Rev#review.id), + ?assertEqual(hidden, Hidden#review.status). + +test_censor() -> + add_word(<<"bad">>), + {ok, _} = core_automod_settings:update([{keyword_action, censor}], admin_id()), + {ok, censor, [Out], _} = logic_automoderation:evaluate_texts([<<"This is bad">>]), + ?assertEqual(<<"This is ***">>, Out). + +test_settings() -> + AdminId = admin_id(), + {ok, S} = logic_automoderation:update_settings(AdminId, [ + {keyword_action, reject}, + {report_threshold, 5} + ]), + ?assertEqual(reject, S#automod_settings.keyword_action), + ?assertEqual(5, S#automod_settings.report_threshold). + +test_settings_denied() -> + ModId = moderator_id(), + ?assertEqual({error, access_denied}, + logic_automoderation:update_settings(ModId, [{report_threshold, 9}])). + +test_settings_invalid() -> + AdminId = admin_id(), + ?assertMatch({error, {invalid_field, _}}, + logic_automoderation:update_settings(AdminId, [{keyword_action, nope}])). + +test_approve_hit() -> + add_word(<<"spam">>), + AdminId = admin_id(), + {ok, _} = core_automod_settings:update([{keyword_action, flag}], AdminId), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"spam">>, eh_test_support:future_start(), 60), + ok = logic_automoderation:apply_after_save(event, Ev#event.id, flag, [<<"spam">>]), + [Hit] = core_automod_hit:list([{status, open}]), + {ok, Resolved} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, approved), + ?assertEqual(approved, Resolved#automod_hit.status), + {ok, Active} = core_event:get_by_id(Ev#event.id), + ?assertEqual(active, Active#event.status). + +test_reject_hit() -> + add_word(<<"spam">>), + AdminId = admin_id(), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"ok">>, eh_test_support:future_start(), 60), + {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [<<"spam">>], flag), + {ok, _} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, rejected), + {ok, Frozen} = core_event:get_by_id(Ev#event.id), + ?assertEqual(frozen, Frozen#event.status). + +test_resolve_twice() -> + AdminId = admin_id(), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"ok">>, eh_test_support:future_start(), 60), + {ok, Hit} = core_automod_hit:create(event, Ev#event.id, keyword, [], flag), + {ok, _} = logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, approved), + ?assertEqual({error, already_resolved}, + logic_automoderation:resolve_hit(AdminId, Hit#automod_hit.id, rejected)). + +test_report_threshold() -> + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r1">>), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r2">>), + {ok, Ev2} = core_event:get_by_id(Ev#event.id), + ?assertEqual(active, Ev2#event.status), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r3">>), + {ok, Ev3} = core_event:get_by_id(Ev#event.id), + ?assertEqual(frozen, Ev3#event.status), + ?assert(length(core_automod_hit:list([{trigger, report_threshold}])) >= 1). + +test_dismissed_not_counted() -> + AdminId = admin_id(), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + {ok, R1} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r1">>), + {ok, R2} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r2">>), + {ok, _} = logic_moderation:resolve_report(AdminId, R1#report.id, dismissed), + {ok, _} = logic_moderation:resolve_report(AdminId, R2#report.id, dismissed), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"r3">>), + {ok, Ev2} = core_event:get_by_id(Ev#event.id), + ?assertEqual(active, Ev2#event.status). + +test_custom_threshold() -> + {ok, _} = core_automod_settings:update([{report_threshold, 2}], admin_id()), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"a">>), + {ok, Ev1} = core_event:get_by_id(Ev#event.id), + ?assertEqual(active, Ev1#event.status), + {ok, _} = logic_moderation:create_report(seed_user(), event, Ev#event.id, <<"b">>), + {ok, Ev2} = core_event:get_by_id(Ev#event.id), + ?assertEqual(frozen, Ev2#event.status). + +test_report_review() -> + Owner = seed_user(), + Reviewer = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 3, <<"ok">>), + {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"x">>), + {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"y">>), + {ok, _} = logic_moderation:create_report(seed_user(), review, Rev#review.id, <<"z">>), + {ok, Hidden} = core_review:get_by_id(Rev#review.id), + ?assertEqual(hidden, Hidden#review.status). + +test_logic_calendar_reject() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, reject}], admin_id()), + Owner = seed_user(), + ?assertMatch({error, {content_banned, _}}, + logic_calendar:create_calendar(Owner, <<"spam title">>, <<"d">>, manual)). + +test_logic_event_flag() -> + add_word(<<"spam">>), + {ok, _} = core_automod_settings:update([{keyword_action, flag}], admin_id()), + Owner = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = logic_event:create_event(Owner, Cal#calendar.id, <<"spam party">>, + eh_test_support:future_start(), 60), + ?assertEqual(frozen, Ev#event.status). + +test_logic_review_censor() -> + add_word(<<"bad">>), + {ok, _} = core_automod_settings:update([{keyword_action, censor}], admin_id()), + Owner = seed_user(), + Reviewer = seed_user(), + {ok, Cal} = core_calendar:create(Owner, <<"Cal">>, <<>>, manual), + {ok, Ev} = core_event:create(Cal#calendar.id, <<"E">>, eh_test_support:future_start(), 60), + %% can_review may fail without booking — use evaluate + core like create path + {ok, censor, [Comment], Words} = logic_automoderation:evaluate_texts([<<"really bad">>]), + {ok, Rev} = core_review:create(Reviewer, event, Ev#event.id, 2, Comment), + ok = logic_automoderation:apply_after_save(review, Rev#review.id, censor, Words), + {ok, Saved} = core_review:get_by_id(Rev#review.id), + ?assertEqual(<<"really ***">>, Saved#review.comment), + ?assertEqual(1, length(core_automod_hit:list([{action_taken, censor}]))). diff --git a/test/unit/logic_moderation_tests.erl b/test/unit/logic_moderation_tests.erl index ddea9ad..03660c2 100644 --- a/test/unit/logic_moderation_tests.erl +++ b/test/unit/logic_moderation_tests.erl @@ -2,7 +2,8 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, admin, calendar, event, report, banned_word]). +-define(TABLES, [user, admin, admin_audit, calendar, event, report, banned_word, + automod_settings, automod_hit, review]). %% ---------------------------------------------------------------- %% Фикстуры @@ -10,6 +11,7 @@ setup() -> eh_test_support:start_mnesia(), eh_test_support:ensure_tables(?TABLES), + core_automod_settings:ensure_defaults(), ok. cleanup(_) ->