diff --git a/big_tests/tests/mam_SUITE.erl b/big_tests/tests/mam_SUITE.erl index 00b375a973e..4172d9aa32d 100644 --- a/big_tests/tests/mam_SUITE.erl +++ b/big_tests/tests/mam_SUITE.erl @@ -99,6 +99,8 @@ server_returns_item_not_found_for_before_filter_with_nonexistent_id/1, server_returns_item_not_found_for_after_filter_with_nonexistent_id/1, server_returns_item_not_found_for_after_filter_with_invalid_id/1, + server_returns_item_not_found_for_ids_filter_with_nonexistent_id/1, + muc_server_returns_item_not_found_for_ids_filter_with_nonexistent_id/1, %% complete_flag_cases tests before_complete_false_last5/1, before_complete_false_before10/1, @@ -120,6 +122,10 @@ offline_message/1, nostore_hint/1, querying_for_all_messages_with_jid/1, + query_messages_by_ids/1, + simple_query_messages_by_ids/1, + muc_query_messages_by_ids/1, + muc_simple_query_messages_by_ids/1, muc_querying_for_all_messages/1, muc_querying_for_all_messages_with_jid/1, muc_light_service_discovery_stored_in_pm/1, @@ -181,6 +187,8 @@ stanza_archive_request/2, stanza_text_search_archive_request/3, stanza_include_groupchat_request/3, + stanza_fetch_by_id_request/3, + stanza_fetch_by_id_request/4, stanza_date_range_archive_request_not_empty/3, wait_archive_respond/1, wait_for_complete_archive_response/3, @@ -209,6 +217,8 @@ wait_message_range/3, wait_message_range/5, message_id/2, + get_pre_generated_msgs_ids/2, + get_received_msgs_ids/1, stanza_prefs_set_request/4, stanza_prefs_get_request/1, stanza_query_get_request/1, @@ -216,10 +226,12 @@ mam_ns_binary/0, mam_ns_binary_v04/0, mam_ns_binary_v06/0, + mam_ns_binary_extended/0, retract_ns/0, retract_tombstone_ns/0, groupchat_field_ns/0, groupchat_available_ns/0, + data_validate_ns/0, make_alice_and_bob_friends/2, run_prefs_case/6, prefs_cases2/0, @@ -353,7 +365,8 @@ basic_groups() -> [{mam_metrics, [], mam_metrics_cases()}, {mam04, [parallel], mam_cases() ++ [retrieve_form_fields] ++ text_search_cases()}, {mam06, [parallel], mam_cases() ++ [retrieve_form_fields_extra_features] - ++ stanzaid_cases() ++ retract_cases() ++ metadata_cases()}, + ++ stanzaid_cases() ++ retract_cases() + ++ metadata_cases() ++ fetch_specific_msgs_cases()}, {nostore, [parallel], nostore_cases()}, {archived, [parallel], archived_cases()}, {configurable_archiveid, [], configurable_archiveid_cases()}, @@ -373,7 +386,7 @@ basic_groups() -> {muc_all, [parallel], [{muc04, [parallel], muc_cases() ++ muc_text_search_cases()}, {muc06, [parallel], muc_cases() ++ muc_stanzaid_cases() ++ muc_retract_cases() - ++ muc_metadata_cases()}, + ++ muc_metadata_cases() ++ muc_fetch_specific_msgs_cases()}, {muc_configurable_archiveid, [], muc_configurable_archiveid_cases()}, {muc_rsm_all, [parallel], [{muc_rsm04, [parallel], muc_rsm_cases()}]}]}, @@ -444,6 +457,13 @@ metadata_cases() -> metadata_archive_request_one_message ]. +fetch_specific_msgs_cases() -> + [ + query_messages_by_ids, + simple_query_messages_by_ids, + server_returns_item_not_found_for_ids_filter_with_nonexistent_id + ]. + muc_text_search_cases() -> [ muc_text_search_request @@ -510,6 +530,13 @@ muc_metadata_cases() -> muc_metadata_archive_request_one_message ]. +muc_fetch_specific_msgs_cases() -> + [ + muc_query_messages_by_ids, + muc_simple_query_messages_by_ids, + muc_server_returns_item_not_found_for_ids_filter_with_nonexistent_id + ]. + configurable_archiveid_cases() -> [no_elements, only_stanzaid, @@ -828,6 +855,11 @@ init_per_testcase(C=filter_forwarded, Config) -> init_per_testcase(C=querying_for_all_messages_with_jid, Config) -> Config1 = escalus_fresh:create_users(Config, [{alice, 1}, {bob, 1}, {carol, 1}]), escalus:init_per_testcase(C, bootstrap_archive(Config1)); +init_per_testcase(C, Config) when C =:= query_messages_by_ids; + C =:= simple_query_messages_by_ids; + C =:= server_returns_item_not_found_for_ids_filter_with_nonexistent_id -> + Config1 = escalus_fresh:create_users(Config, [{alice, 1}, {bob, 1}, {carol, 1}]), + escalus:init_per_testcase(C, bootstrap_archive(Config1)); init_per_testcase(C=archived, Config) -> Config1 = escalus_fresh:create_users(Config, [{alice, 1}, {bob, 1}]), escalus:init_per_testcase(C, Config1); @@ -846,6 +878,11 @@ init_per_testcase(C=offline_message, Config) -> escalus:init_per_testcase(C, Config1); init_per_testcase(C=nostore_hint, Config) -> escalus:init_per_testcase(C, Config); +init_per_testcase(C, Config) when C =:= muc_query_messages_by_ids; + C =:= muc_simple_query_messages_by_ids; + C =:= muc_server_returns_item_not_found_for_ids_filter_with_nonexistent_id -> + Config1 = escalus_fresh:create_users(Config, [{alice, 1}, {bob, 1}]), + escalus:init_per_testcase(C, muc_bootstrap_archive(start_alice_room(Config1))); init_per_testcase(C=muc_querying_for_all_messages, Config) -> Config1 = escalus_fresh:create_users(Config, [{alice, 1}, {bob, 1}]), escalus:init_per_testcase(C, @@ -1056,6 +1093,11 @@ end_per_testcase(C=muc_show_x_user_to_moderators_in_anon_rooms, Config) -> end_per_testcase(C=muc_show_x_user_for_your_own_messages_in_anon_rooms, Config) -> destroy_room(Config), escalus:end_per_testcase(C, Config); +end_per_testcase(C, Config) when C =:= muc_query_messages_by_ids; + C =:= muc_simple_query_messages_by_ids; + C =:= muc_server_returns_item_not_found_for_ids_filter_with_nonexistent_id -> + destroy_room(Config), + escalus:end_per_testcase(C, Config); end_per_testcase(C=muc_querying_for_all_messages, Config) -> destroy_room(Config), escalus:end_per_testcase(C, Config); @@ -1648,6 +1690,121 @@ querying_for_all_messages_with_jid(Config) -> end, escalus:story(Config, [{alice, 1}], F). +query_messages_by_ids(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Msgs = ?config(pre_generated_msgs, Config), + IDs = get_pre_generated_msgs_ids(Msgs, [5, 10]), + + Stanza = stanza_fetch_by_id_request(P, <<"fetch-msgs-by-ids">>, IDs), + escalus:send(Alice, Stanza), + + Result = wait_archive_respond(Alice), + ResultIDs = get_received_msgs_ids(Result), + + assert_respond_size(2, Result), + ?assert_equal(lists:sort(ResultIDs), lists:sort(IDs)), + ok + end, + escalus:story(Config, [{alice, 1}], F). + +simple_query_messages_by_ids(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Msgs = ?config(pre_generated_msgs, Config), + [ID1, ID2, ID5] = get_pre_generated_msgs_ids(Msgs, [1, 2, 5]), + + RSM = #rsm_in{max = 10, direction = 'after', id = ID1, simple = true}, + Stanza = stanza_fetch_by_id_request(P, <<"simple-fetch-msgs-by-ids">>, [ID2, ID5], RSM), + escalus:send(Alice, Stanza), + + Result = wait_archive_respond(Alice), + ParsedIQ = parse_result_iq(Result), + ResultIDs = get_received_msgs_ids(Result), + + ?assert_equal(lists:sort(ResultIDs), lists:sort([ID2, ID5])), + ?assert_equal(undefined, ParsedIQ#result_iq.count), + ?assert_equal(undefined, ParsedIQ#result_iq.first_index), + ok + end, + escalus:story(Config, [{alice, 1}], F). + +server_returns_item_not_found_for_ids_filter_with_nonexistent_id(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Msgs = ?config(pre_generated_msgs, Config), + IDs = get_pre_generated_msgs_ids(Msgs, [3, 12]), + NonexistentID = <<"AV25E9SCO50K">>, + + Stanza = stanza_fetch_by_id_request(P, <<"ids-not-found">>, IDs ++ [NonexistentID]), + escalus:send(Alice, Stanza), + Result = escalus:wait_for_stanza(Alice), + + escalus:assert(is_iq_error, [Stanza], Result), + escalus:assert(is_error, [<<"cancel">>, <<"item-not-found">>], Result), + ok + end, + escalus:story(Config, [{alice, 1}], F). + +muc_query_messages_by_ids(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Room = ?config(room, Config), + Msgs = ?config(pre_generated_muc_msgs, Config), + IDs = get_pre_generated_msgs_ids(Msgs, [5, 10]), + + Stanza = stanza_fetch_by_id_request(P, <<"fetch-muc-msgs-by-ids">>, IDs), + escalus:send(Alice, stanza_to_room(Stanza, Room)), + + Result = wait_archive_respond(Alice), + ResultIDs = get_received_msgs_ids(Result), + + assert_respond_size(2, Result), + ?assert_equal(lists:sort(ResultIDs), lists:sort(IDs)), + ok + end, + escalus:story(Config, [{alice, 1}], F). + +muc_simple_query_messages_by_ids(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Room = ?config(room, Config), + Msgs = ?config(pre_generated_muc_msgs, Config), + [ID1, ID2, ID5] = get_pre_generated_msgs_ids(Msgs, [1, 2, 5]), + + RSM = #rsm_in{max = 10, direction = 'after', id = ID1, simple = true}, + Stanza = stanza_fetch_by_id_request(P, <<"muc-simple-fetch-msgs-by-ids">>, [ID2, ID5], RSM), + escalus:send(Alice, stanza_to_room(Stanza, Room)), + + Result = wait_archive_respond(Alice), + ParsedIQ = parse_result_iq(Result), + ResultIDs = get_received_msgs_ids(Result), + + ?assert_equal(lists:sort(ResultIDs), lists:sort([ID2, ID5])), + ?assert_equal(undefined, ParsedIQ#result_iq.count), + ?assert_equal(undefined, ParsedIQ#result_iq.first_index), + ok + end, + escalus:story(Config, [{alice, 1}], F). + +muc_server_returns_item_not_found_for_ids_filter_with_nonexistent_id(Config) -> + P = ?config(props, Config), + F = fun(Alice) -> + Room = ?config(room, Config), + Msgs = ?config(pre_generated_muc_msgs, Config), + IDs = get_pre_generated_msgs_ids(Msgs, [3, 12]), + NonexistentID = <<"AV25E9SCO50K">>, + + Stanza = stanza_fetch_by_id_request(P, <<"muc-ids-not-found">>, IDs ++ [NonexistentID]), + escalus:send(Alice, stanza_to_room(Stanza, Room)), + Result = escalus:wait_for_stanza(Alice), + + escalus:assert(is_iq_error, [Stanza], Result), + escalus:assert(is_error, [<<"cancel">>, <<"item-not-found">>], Result), + ok + end, + escalus:story(Config, [{alice, 1}], F). + muc_querying_for_all_messages(Config) -> P = ?config(props, Config), F = fun(Alice) -> @@ -1943,9 +2100,13 @@ retrieve_form_fields_extra_features(ConfigIn) -> escalus:assert(is_iq_with_ns, [Namespace], Res), QueryEl = exml_query:subelement(Res, <<"query">>), XEl = exml_query:subelement(QueryEl, <<"x">>), + IDsEl = exml_query:subelement_with_attr(XEl, <<"var">>, <<"ids">>), + ValidateEl = exml_query:path(IDsEl, [{element_with_ns, <<"validate">>, data_validate_ns()}, + {element, <<"open">>}]), escalus:assert(has_field_with_type, [<<"before-id">>, <<"text-single">>], XEl), escalus:assert(has_field_with_type, [<<"after-id">>, <<"text-single">>], XEl), - escalus:assert(has_field_with_type, [<<"include-groupchat">>, <<"boolean">>], XEl) + escalus:assert(has_field_with_type, [<<"include-groupchat">>, <<"boolean">>], XEl), + ?assertNotEqual(ValidateEl, undefined) end). archived(Config) -> @@ -3406,6 +3567,7 @@ discover_features(Config, Client, Service) -> escalus:assert(is_iq_result, Stanza), escalus:assert(has_feature, [mam_ns_binary_v04()], Stanza), escalus:assert(has_feature, [mam_ns_binary_v06()], Stanza), + escalus:assert(has_feature, [mam_ns_binary_extended()], Stanza), escalus:assert(has_feature, [retract_ns()], Stanza), check_include_groupchat_features(Stanza, ?config(configuration, Config), diff --git a/big_tests/tests/mam_helper.erl b/big_tests/tests/mam_helper.erl index ff75e4502bc..80ad2f51433 100644 --- a/big_tests/tests/mam_helper.erl +++ b/big_tests/tests/mam_helper.erl @@ -57,6 +57,8 @@ stanza_archive_request/2, stanza_text_search_archive_request/3, stanza_include_groupchat_request/3, + stanza_fetch_by_id_request/3, + stanza_fetch_by_id_request/4, stanza_date_range_archive_request_not_empty/3, wait_archive_respond/1, wait_for_complete_archive_response/3, @@ -88,6 +90,8 @@ wait_message_range/3, wait_message_range/5, message_id/2, + get_pre_generated_msgs_ids/2, + get_received_msgs_ids/1, stanza_prefs_set_request/4, stanza_prefs_get_request/1, stanza_query_get_request/1, @@ -96,11 +100,13 @@ mam_ns_binary/0, mam_ns_binary_v04/0, mam_ns_binary_v06/0, + mam_ns_binary_extended/0, retract_ns/0, retract_esl_ns/0, retract_tombstone_ns/0, groupchat_field_ns/0, groupchat_available_ns/0, + data_validate_ns/0, make_alice_and_bob_friends/2, run_prefs_case/6, prefs_cases2/0, @@ -253,6 +259,7 @@ nick(User) -> namespaces() -> [mam_ns_binary_v04(), mam_ns_binary_v06(), + mam_ns_binary_extended(), retract_ns(), retract_esl_ns(), retract_tombstone_ns()]. @@ -260,11 +267,13 @@ namespaces() -> mam_ns_binary() -> mam_ns_binary_v04(). mam_ns_binary_v04() -> <<"urn:xmpp:mam:1">>. mam_ns_binary_v06() -> <<"urn:xmpp:mam:2">>. +mam_ns_binary_extended() -> <<"urn:xmpp:mam:2#extended">>. retract_ns() -> <<"urn:xmpp:message-retract:0">>. retract_esl_ns() -> <<"urn:esl:message-retract-by-stanza-id:0">>. retract_tombstone_ns() -> <<"urn:xmpp:message-retract:0#tombstone">>. groupchat_field_ns() -> <<"urn:xmpp:mam:2#groupchat-field">>. groupchat_available_ns() -> <<"urn:xmpp:mam:2#groupchat-available">>. +data_validate_ns() -> <<"http://jabber.org/protocol/xdata-validate">>. skip_undefined(Xs) -> [X || X <- Xs, X =/= undefined]. @@ -297,21 +306,21 @@ stanza_archive_request(P, QueryId) -> stanza_date_range_archive_request(P) -> Params = #{ - start => "2010-06-07T00:00:00Z", - stop => "2010-07-07T13:23:54Z" + start => <<"2010-06-07T00:00:00Z">>, + stop => <<"2010-07-07T13:23:54Z">> }, stanza_lookup_messages_iq(P, Params). stanza_date_range_archive_request_not_empty(P, Start, Stop) -> Params = #{ - start => Start, - stop => Stop + start => list_to_binary(Start), + stop => list_to_binary(Stop) }, stanza_lookup_messages_iq(P, Params). stanza_limit_archive_request(P) -> Params = #{ - start => "2010-08-07T00:00:00Z", + start => <<"2010-08-07T00:00:00Z">>, rsm => #rsm_in{max=10} }, stanza_lookup_messages_iq(P, Params). @@ -349,6 +358,17 @@ stanza_include_groupchat_request(P, QueryId, IncludeGroupChat) -> }, stanza_lookup_messages_iq(P, Params). +stanza_fetch_by_id_request(P, QueryId, IDs) -> + stanza_fetch_by_id_request(P, QueryId, IDs, undefined). + +stanza_fetch_by_id_request(P, QueryId, IDs, RSM) -> + Params = #{ + query_id => QueryId, + messages_ids => IDs, + rsm => RSM + }, + stanza_lookup_messages_iq(P, Params). + stanza_lookup_messages_iq(P, Params) -> QueryId = maps:get(query_id, Params, undefined), BStart = maps:get(start, Params, undefined), @@ -358,25 +378,27 @@ stanza_lookup_messages_iq(P, Params) -> TextSearch = maps:get(text_search, Params, undefined), FlipPage = maps:get(flip_page, Params, undefined), IncludeGroupChat = maps:get(include_group_chat, Params, undefined), + MessagesIDs = maps:get(messages_ids, Params, undefined), escalus_stanza:iq(<<"set">>, [#xmlel{ name = <<"query">>, attrs = mam_ns_attr(P) ++ maybe_attr(<<"queryid">>, QueryId), children = skip_undefined([ - form_x(BStart, BEnd, BWithJID, RSM, TextSearch, IncludeGroupChat), + form_x(BStart, BEnd, BWithJID, RSM, TextSearch, IncludeGroupChat, MessagesIDs), maybe_rsm_elem(RSM), maybe_flip_page(FlipPage)]) }]). -form_x(undefined, undefined, undefined, undefined, undefined, undefined) -> +form_x(undefined, undefined, undefined, undefined, undefined, undefined, undefined) -> undefined; -form_x(BStart, BEnd, BWithJID, RSM, TextSearch, IncludeGroupChat) -> +form_x(BStart, BEnd, BWithJID, RSM, TextSearch, IncludeGroupChat, MessagesIDs) -> Fields = skip_undefined([form_field(<<"start">>, BStart), form_field(<<"end">>, BEnd), form_field(<<"with">>, BWithJID), form_field(<<"full-text-search">>, TextSearch), - form_field(<<"include-groupchat">>, IncludeGroupChat)] + form_field(<<"include-groupchat">>, IncludeGroupChat), + form_field(<<"ids">>, MessagesIDs)] ++ form_extra_fields(RSM) ++ form_border_fields(RSM)), form_helper:form(#{fields => Fields}). @@ -397,6 +419,8 @@ form_border_fields(#rsm_in{ form_field(_VarName, undefined) -> undefined; +form_field(VarName, VarValues) when is_list(VarValues) -> + #{var => VarName, values => VarValues}; form_field(VarName, VarValue) -> #{var => VarName, values => [VarValue]}. @@ -585,6 +609,20 @@ message_id(Num, Config) -> #forwarded_message{result_id=Id} = lists:nth(Num, AllMessages), Id. +get_pre_generated_msgs_ids(Msgs, Nums) -> + lists:map(fun(N) -> + Msg = lists:nth(N, Msgs), + {{MsgID, _}, _, _, _, _} = Msg, + rpc_apply(mod_mam_utils, mess_id_to_external_binary, [MsgID]) + end, Nums). + +get_received_msgs_ids(Response) -> + Msgs = respond_messages(Response), + lists:map(fun(M) -> + Parsed = parse_forwarded_message(M), + Parsed#forwarded_message.result_id + end, Msgs). + %% @doc Result query iq. %% %% [{xmlel,<<"iq">>, diff --git a/doc/modules/mod_mam.md b/doc/modules/mod_mam.md index 0e616ee5711..2d23ed44286 100644 --- a/doc/modules/mod_mam.md +++ b/doc/modules/mod_mam.md @@ -5,7 +5,7 @@ It enables a service to store all user messages for one-to-one chats as well as It uses [XEP-0059: Result Set Management](http://xmpp.org/extensions/xep-0059.html) for paging. It is a highly customizable module, that requires some skill and knowledge to operate properly and efficiently. -MongooseIM is compatible with MAM 0.4-0.6. +MongooseIM is compatible with MAM 0.4-1.1.0. Configure MAM with different storage backends: @@ -75,7 +75,7 @@ Database backend to use. * **Default:** `false` * **Example:** `no_stanzaid_element = true` -Do not add a `` element from MAM v0.6. +Do not add a `` element from MAM v1.1.0. ### `modules.mod_mam.is_archivable_message` * **Syntax:** non-empty string diff --git a/include/mongoose_ns.hrl b/include/mongoose_ns.hrl index ebf1fb5d895..ba3abd6ea1a 100644 --- a/include/mongoose_ns.hrl +++ b/include/mongoose_ns.hrl @@ -58,6 +58,7 @@ -define(NS_SERVERINFO, <<"http://jabber.org/network/serverinfo">>). -define(NS_MAM_04, <<"urn:xmpp:mam:1">>). % MAM 0.4.1 or 0.5 -define(NS_MAM_06, <<"urn:xmpp:mam:2">>). % MAM 0.6 +-define(NS_MAM_EXTENDED, <<"urn:xmpp:mam:2#extended">>). -define(NS_MAM_GC_FIELD, <<"urn:xmpp:mam:2#groupchat-field">>). -define(NS_MAM_GC_AVAILABLE, <<"urn:xmpp:mam:2#groupchat-available">>). -define(NS_HTTP_UPLOAD_030, <<"urn:xmpp:http:upload:0">>). @@ -108,6 +109,8 @@ -define(JINGLE_NS, <<"urn:xmpp:jingle:1">>). +-define(NS_DATA_VALIDATE, <<"http://jabber.org/protocol/xdata-validate">>). + %% Custom extension to accept stanza-ids as retraction IDs -define(NS_ESL_RETRACT, <<"urn:esl:message-retract-by-stanza-id:0">>). diff --git a/src/mam/mam_iq.erl b/src/mam/mam_iq.erl index 988fffd642e..ac9151c2ed6 100644 --- a/src/mam/mam_iq.erl +++ b/src/mam/mam_iq.erl @@ -38,8 +38,10 @@ with_jid => jid:jid() | undefined, %% Filtering by body text search_text => binary() | undefined, - %% Filtering Result Set based on message ids + %% Filtering Result Set before/after specific message ids borders => mod_mam:borders() | undefined, + %% Filtering Result Set based on specific message ids + message_ids => [mod_mam:message_id()] | undefined, %% Affects 'policy-violation' for a case when: %% - user does not use forms to query archive %% - user does not provide "set" element @@ -145,6 +147,7 @@ form_to_lookup_params(#iq{sub_el = QueryEl} = IQ, MaxResultLimit, DefaultResultL search_text => mod_mam_utils:form_to_text(KVs), borders => mod_mam_utils:form_borders_decode(KVs), + message_ids => form_to_msg_ids(KVs), %% Whether or not the client query included a element, %% the server MAY simply return its limited results. %% So, disable 'policy-violation'. @@ -205,3 +208,8 @@ include_groupchat(#{<<"include-groupchat">> := [<<"false">>]}) -> false; include_groupchat(_) -> undefined. + +form_to_msg_ids(#{<<"ids">> := IDs}) -> + [mod_mam_utils:external_binary_to_mess_id(ID) || ID <- IDs]; +form_to_msg_ids(_) -> + undefined. diff --git a/src/mam/mod_mam.erl b/src/mam/mod_mam.erl index 33be3ef0341..0f5b0a15b8d 100644 --- a/src/mam/mod_mam.erl +++ b/src/mam/mod_mam.erl @@ -17,7 +17,7 @@ -module(mod_mam). -behaviour(gen_mod). -behaviour(mongoose_module_metrics). --xep([{xep, 313}, {version, "1.1.0"}, {status, partial}, {legacy_versions, ["0.5"]}]). +-xep([{xep, 313}, {version, "1.1.0"}, {legacy_versions, ["0.5"]}]). -xep([{xep, 424}, {version, "0.3.0"}]). -include("mod_mam.hrl"). diff --git a/src/mam/mod_mam_cassandra_arch.erl b/src/mam/mod_mam_cassandra_arch.erl index 9acf29ae83a..db9f526b2d8 100644 --- a/src/mam/mod_mam_cassandra_arch.erl +++ b/src/mam/mod_mam_cassandra_arch.erl @@ -119,7 +119,7 @@ prepared_queries() -> Extra :: gen_hook:extra(). archive_size(Size, #{owner := UserJID}, #{host_type := HostType}) when is_integer(Size) -> Borders = Start = End = WithJID = undefined, - Filter = prepare_filter(UserJID, Borders, Start, End, WithJID), + Filter = prepare_filter(UserJID, Borders, Start, End, WithJID, undefined), {ok, calc_count(pool_name(HostType), UserJID, HostType, Filter)}. @@ -229,13 +229,13 @@ lookup_messages(_Result, #{owner_jid := UserJID, rsm := RSM, borders := Borders, start_ts := Start, end_ts := End, with_jid := WithJID, search_text := undefined, page_size := PageSize, - is_simple := IsSimple}, + is_simple := IsSimple, message_id := MsgID}, #{host_type := HostType}) -> try {ok, lookup_messages2(pool_name(HostType), HostType, UserJID, RSM, Borders, Start, End, WithJID, - PageSize, IsSimple)} + PageSize, MsgID, IsSimple)} catch _Type:Reason:S -> {ok, {error, {Reason, S}}} end. @@ -243,20 +243,20 @@ lookup_messages(_Result, lookup_messages2(PoolName, HostType, UserJID = #jid{}, RSM, Borders, Start, End, WithJID, - PageSize, _IsSimple = true) -> + PageSize, MsgID, _IsSimple = true) -> %% Simple query without calculating offset and total count - Filter = prepare_filter(UserJID, Borders, Start, End, WithJID), + Filter = prepare_filter(UserJID, Borders, Start, End, WithJID, MsgID), lookup_messages_simple(PoolName, HostType, UserJID, RSM, PageSize, Filter); lookup_messages2(PoolName, HostType, UserJID = #jid{}, RSM, Borders, Start, End, WithJID, - PageSize, _IsSimple) -> + PageSize, MsgID, _IsSimple) -> %% Query with offset calculation %% We cannot just use RDBMS code because "LIMIT X, Y" is not supported by cassandra %% Not all queries are optimal. You would like to disable something for production %% once you know how you will call bd Strategy = rsm_to_strategy(RSM), - Filter = prepare_filter(UserJID, Borders, Start, End, WithJID), + Filter = prepare_filter(UserJID, Borders, Start, End, WithJID, MsgID), case Strategy of last_page -> lookup_messages_last_page(PoolName, HostType, UserJID, RSM, PageSize, Filter); @@ -602,9 +602,17 @@ prev_offset_query_cql() -> insert_offset_hint_query_cql() -> "INSERT INTO mam_message_offset(user_jid, with_jid, id, offset) VALUES(?, ?, ?, ?)". -prepare_filter(UserJID, Borders, Start, End, WithJID) -> +prepare_filter(UserJID, Borders, Start, End, WithJID, MsgID) -> BUserJID = bare_jid(UserJID), - {StartID, EndID} = mod_mam_utils:calculate_msg_id_borders(Borders, Start, End), + %% In Cassandra, a column cannot be restricted by both an equality and an inequality relation. + %% When MsgID is defined, it is used as both StartID and EndID to comply with this limitation. + %% This means that the `ids` filter effectively overrides any "before" or "after" filters. + {StartID, EndID} = case MsgID of + undefined -> + mod_mam_utils:calculate_msg_id_borders(Borders, Start, End); + ID -> + {ID, ID} + end, BWithJID = maybe_full_jid(WithJID), %% it's NOT optional field prepare_filter_params(BUserJID, BWithJID, StartID, EndID). diff --git a/src/mam/mod_mam_elasticsearch_arch.erl b/src/mam/mod_mam_elasticsearch_arch.erl index 578b88129e9..f90bccb9e4e 100644 --- a/src/mam/mod_mam_elasticsearch_arch.erl +++ b/src/mam/mod_mam_elasticsearch_arch.erl @@ -118,12 +118,17 @@ lookup_messages(Result, lookup_messages(Result, Params, #{host_type := HostType}) -> {ok, do_lookup_messages(Result, HostType, Params)}. -lookup_message_page(AccResult, Host, #rsm_in{id = _ID} = RSM, Params) -> +lookup_message_page(AccResult, Host, #rsm_in{id = _ID} = RSM, #{message_id := MsgID} = Params) -> PageSize = maps:get(page_size, Params), case do_lookup_messages(AccResult, Host, Params#{page_size := 1 + PageSize}) of {error, _} = Err -> Err; {ok, LookupResult} -> - mod_mam_utils:check_for_item_not_found(RSM, PageSize, LookupResult) + case MsgID of + undefined -> + mod_mam_utils:check_for_item_not_found(RSM, PageSize, LookupResult); + _ -> + {ok, LookupResult} + end end. do_lookup_messages(_Result, Host, Params) -> @@ -213,6 +218,7 @@ build_filters(Params) -> Builders = [fun owner_filter/1, fun with_jid_filter/1, fun is_groupchat_filter/1, + fun specific_message_filter/1, fun range_filter/1], lists:flatmap(fun(F) -> F(Params) end, Builders). @@ -233,6 +239,12 @@ is_groupchat_filter(#{include_groupchat := false}) -> is_groupchat_filter(_) -> []. +-spec specific_message_filter(map()) -> [map()]. +specific_message_filter(#{message_id := ID}) when is_integer(ID) -> + [#{term => #{mam_id => ID}}]; +specific_message_filter(_) -> + []. + -spec range_filter(map()) -> [map()]. range_filter(#{end_ts := End, start_ts := Start, borders := Borders, rsm := RSM}) -> {StartId, EndId} = mod_mam_utils:calculate_msg_id_borders(RSM, Borders, Start, End), diff --git a/src/mam/mod_mam_muc.erl b/src/mam/mod_mam_muc.erl index 98de0354a38..625543978c7 100644 --- a/src/mam/mod_mam_muc.erl +++ b/src/mam/mod_mam_muc.erl @@ -524,7 +524,14 @@ lookup_messages_without_policy_violation_check(HostType, {error, 'not-supported'}; false -> StartT = erlang:monotonic_time(microsecond), - R = mongoose_hooks:mam_muc_lookup_messages(HostType, Params), + R = case maps:get(message_ids, Params, undefined) of + undefined -> + mongoose_hooks:mam_muc_lookup_messages(HostType, + Params#{message_id => undefined}); + IDs -> + mod_mam_utils:lookup_specific_messages(HostType, Params, IDs, + fun mongoose_hooks:mam_muc_lookup_messages/2) + end, Diff = erlang:monotonic_time(microsecond) - StartT, mongoose_metrics:update(HostType, [backends, ?MODULE, lookup], Diff), R diff --git a/src/mam/mod_mam_muc_cassandra_arch.erl b/src/mam/mod_mam_muc_cassandra_arch.erl index 9e7aba451c7..0bccd61ec89 100644 --- a/src/mam/mod_mam_muc_cassandra_arch.erl +++ b/src/mam/mod_mam_muc_cassandra_arch.erl @@ -120,7 +120,7 @@ prepared_queries() -> archive_size(Size, #{room := RoomJID}, #{host_type := HostType}) when is_integer(Size) -> PoolName = pool_name(HostType), Borders = Start = End = WithNick = undefined, - Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick), + Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick, undefined), {ok, calc_count(PoolName, RoomJID, HostType, Filter)}. @@ -228,7 +228,7 @@ lookup_messages(_Result, #{owner_jid := RoomJID, rsm := RSM, borders := Borders, start_ts := Start, end_ts := End, with_jid := WithJID, search_text := undefined, page_size := PageSize, - is_simple := IsSimple}, + is_simple := IsSimple, message_id := MsgID}, #{host_type := HostType}) -> try WithNick = maybe_jid_to_nick(WithJID), @@ -236,7 +236,7 @@ lookup_messages(_Result, {ok, lookup_messages2(PoolName, HostType, RoomJID, RSM, Borders, Start, End, WithNick, - PageSize, IsSimple)} + PageSize, MsgID, IsSimple)} catch _Type:Reason:Stacktrace -> {ok, {error, {Reason, {stacktrace, Stacktrace}}}} end. @@ -248,20 +248,20 @@ maybe_jid_to_nick(undefined) -> undefined. lookup_messages2(PoolName, HostType, RoomJID = #jid{}, RSM, Borders, Start, End, WithNick, - PageSize, _IsSimple = true) -> + PageSize, MsgID, _IsSimple = true) -> %% Simple query without calculating offset and total count - Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick), + Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick, MsgID), lookup_messages_simple(PoolName, HostType, RoomJID, RSM, PageSize, Filter); lookup_messages2(PoolName, HostType, RoomJID = #jid{}, RSM, Borders, Start, End, WithNick, - PageSize, _IsSimple) -> + PageSize, MsgID, _IsSimple) -> %% Query with offset calculation %% We cannot just use RDBMS code because "LIMIT X, Y" is not supported by cassandra %% Not all queries are optimal. You would like to disable something for production %% once you know how you will call bd Strategy = rsm_to_strategy(RSM), - Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick), + Filter = prepare_filter(RoomJID, Borders, Start, End, WithNick, MsgID), case Strategy of last_page -> lookup_messages_last_page(PoolName, HostType, RoomJID, RSM, PageSize, Filter); @@ -611,12 +611,20 @@ prev_offset_query_cql() -> insert_offset_hint_query_cql() -> "INSERT INTO mam_muc_message_offset(room_jid, with_nick, id, offset) VALUES(?, ?, ?, ?)". -prepare_filter(RoomJID, Borders, Start, End, WithNick) -> +prepare_filter(RoomJID, Borders, Start, End, WithNick, MsgID) -> BRoomJID = mod_mam_utils:bare_jid(RoomJID), StartID = maybe_encode_compact_uuid(Start, 0), EndID = maybe_encode_compact_uuid(End, 255), - StartID2 = apply_start_border(Borders, StartID), - EndID2 = apply_end_border(Borders, EndID), + %% In Cassandra, a column cannot be restricted by both an equality and an inequality relation. + %% When MsgID is defined, it is used as both StartID2 and EndID2 to comply with this limitation. + %% This means that the `ids` filter effectively overrides any "before" or "after" filters. + {StartID2, EndID2} = case MsgID of + undefined -> + {apply_start_border(Borders, StartID), + apply_end_border(Borders, EndID)}; + ID -> + {ID, ID} + end, BWithNick = maybe_nick(WithNick), prepare_filter_params(BRoomJID, BWithNick, StartID2, EndID2). diff --git a/src/mam/mod_mam_muc_elasticsearch_arch.erl b/src/mam/mod_mam_muc_elasticsearch_arch.erl index 4173bd15813..09a2ff31f7e 100644 --- a/src/mam/mod_mam_muc_elasticsearch_arch.erl +++ b/src/mam/mod_mam_muc_elasticsearch_arch.erl @@ -118,13 +118,18 @@ lookup_messages(Result, lookup_messages(Result, Params, #{host_type := HostType}) -> {ok, do_lookup_messages(Result, HostType, Params)}. -lookup_message_page(AccResult, Host, RSM, Params) -> +lookup_message_page(AccResult, Host, RSM, #{message_id := MsgID} = Params) -> PageSize = maps:get(page_size, Params), case do_lookup_messages(AccResult, Host, Params#{page_size := 1 + PageSize}) of {error, _} = Err -> Err; {ok, LookupResult} -> - mod_mam_utils:check_for_item_not_found(RSM, PageSize, LookupResult) + case MsgID of + undefined -> + mod_mam_utils:check_for_item_not_found(RSM, PageSize, LookupResult); + _ -> + {ok, LookupResult} + end end. do_lookup_messages(_Result, Host, Params) -> @@ -208,7 +213,8 @@ build_search_query(Params) -> build_filters(Params) -> Builders = [fun room_filter/1, fun with_jid_filter/1, - fun range_filter/1], + fun range_filter/1, + fun specific_message_filter/1], lists:flatmap(fun(F) -> F(Params) end, Builders). -spec room_filter(map()) -> [map()]. @@ -237,6 +243,12 @@ range_filter(#{end_ts := End, start_ts := Start, borders := Borders, rsm := RSM} range_filter(_) -> []. +-spec specific_message_filter(map()) -> [map()]. +specific_message_filter(#{message_id := ID}) when is_integer(ID) -> + [#{term => #{mam_id => ID}}]; +specific_message_filter(_) -> + []. + -spec maybe_add_end_filter(undefined | mod_mam:message_id(), map()) -> map(). maybe_add_end_filter(undefined, RangeMap) -> RangeMap; diff --git a/src/mam/mod_mam_muc_rdbms_arch.erl b/src/mam/mod_mam_muc_rdbms_arch.erl index 02829c92743..b9e9893139c 100644 --- a/src/mam/mod_mam_muc_rdbms_arch.erl +++ b/src/mam/mod_mam_muc_rdbms_arch.erl @@ -169,7 +169,8 @@ lookup_fields() -> #lookup_field{op = ge, column = id, param = start_id}, #lookup_field{op = le, column = id, param = end_id}, #lookup_field{op = equal, column = nick_name, param = remote_resource}, - #lookup_field{op = like, column = search_body, param = norm_search_text, value_maker = search_words}]. + #lookup_field{op = like, column = search_body, param = norm_search_text, value_maker = search_words}, + #lookup_field{op = equal, column = id, param = message_id}]. -spec env_vars(host_type(), jid:jid()) -> env_vars(). env_vars(HostType, ArcJID) -> diff --git a/src/mam/mod_mam_pm.erl b/src/mam/mod_mam_pm.erl index fa6b2e9c1ba..ac77b776c0e 100644 --- a/src/mam/mod_mam_pm.erl +++ b/src/mam/mod_mam_pm.erl @@ -599,7 +599,14 @@ lookup_messages_without_policy_violation_check( {error, 'not-supported'}; false -> StartT = erlang:monotonic_time(microsecond), - R = mongoose_hooks:mam_lookup_messages(HostType, Params), + R = case maps:get(message_ids, Params, undefined) of + undefined -> + mongoose_hooks:mam_lookup_messages(HostType, + Params#{message_id => undefined}); + IDs -> + mod_mam_utils:lookup_specific_messages(HostType, Params, IDs, + fun mongoose_hooks:mam_lookup_messages/2) + end, Diff = erlang:monotonic_time(microsecond) - StartT, mongoose_metrics:update(HostType, [backends, ?MODULE, lookup], Diff), R diff --git a/src/mam/mod_mam_rdbms_arch.erl b/src/mam/mod_mam_rdbms_arch.erl index ce4a0fcabb8..537834bd1ea 100644 --- a/src/mam/mod_mam_rdbms_arch.erl +++ b/src/mam/mod_mam_rdbms_arch.erl @@ -204,7 +204,8 @@ lookup_fields() -> #lookup_field{op = equal, column = remote_bare_jid, param = remote_bare_jid}, #lookup_field{op = equal, column = remote_resource, param = remote_resource}, #lookup_field{op = like, column = search_body, param = norm_search_text, value_maker = search_words}, - #lookup_field{op = equal, column = is_groupchat, param = include_groupchat}]. + #lookup_field{op = equal, column = is_groupchat, param = include_groupchat}, + #lookup_field{op = equal, column = id, param = message_id}]. -spec env_vars(host_type(), jid:jid()) -> env_vars(). env_vars(HostType, ArcJID) -> diff --git a/src/mam/mod_mam_utils.erl b/src/mam/mod_mam_utils.erl index 09c4f70c77c..28e9188f0b2 100644 --- a/src/mam/mod_mam_utils.erl +++ b/src/mam/mod_mam_utils.erl @@ -84,6 +84,7 @@ check_for_item_not_found/3, maybe_reverse_messages/2, get_msg_id_and_timestamp/1, + lookup_specific_messages/4, is_mam_muc_enabled/2]). %% Ejabberd @@ -416,6 +417,29 @@ get_msg_id_and_timestamp(#{id := MsgID}) -> ExtID = mess_id_to_external_binary(MsgID), {ExtID, list_to_binary(TS)}. +-spec lookup_specific_messages(mongooseim:host_type(), + mam_iq:lookup_params(), + [mod_mam:message_id()], + fun()) -> [mod_mam:message_row()] | {error, item_not_found}. +lookup_specific_messages(HostType, Params, IDs, FetchFun) -> + {FinalOffset, AccumulatedMessages} = lists:foldl( + fun(ID, {_AccOffset, AccMsgs}) -> + {ok, {_, OffsetForID, MessagesForID}} = FetchFun(HostType, Params#{message_id => ID}), + {OffsetForID, AccMsgs ++ MessagesForID} + end, + {0, []}, IDs), + + Result = determine_result(Params, FinalOffset, AccumulatedMessages), + case length(IDs) == length(AccumulatedMessages) of + true -> Result; + false -> {error, item_not_found} + end. + +determine_result(#{is_simple := true}, _Offset, Messages) -> + {ok, {undefined, undefined, Messages}}; +determine_result(#{}, Offset, Messages) -> + {ok, {length(Messages), Offset, Messages}}. + tombstone(RetractionInfo = #{packet := Packet}, LocJid) -> Packet#xmlel{children = [retracted_element(RetractionInfo, LocJid)]}. @@ -713,7 +737,7 @@ features(Module, HostType) -> ++ groupchat_features(Module, HostType). mam_features() -> - [?NS_MAM_04, ?NS_MAM_06]. + [?NS_MAM_04, ?NS_MAM_06, ?NS_MAM_EXTENDED]. retraction_features(Module, HostType) -> case has_message_retraction(Module, HostType) of @@ -766,7 +790,9 @@ message_form_fields(Mod, HostType, <<"urn:xmpp:mam:2">>) -> #{type => <<"text-single">>, var => <<"end">>}, #{type => <<"text-single">>, var => <<"before-id">>}, #{type => <<"text-single">>, var => <<"after-id">>}, - #{type => <<"boolean">>, var => <<"include-groupchat">>} | TextSearch]. + #{type => <<"boolean">>, var => <<"include-groupchat">>}, + #{type => <<"list-multi">>, var => <<"ids">>, + validate => #{method => open, datatype => <<"xs:string">>}} | TextSearch]. -spec form_to_text(_) -> 'undefined' | binary(). form_to_text(#{<<"full-text-search">> := [Text]}) -> @@ -1243,7 +1269,8 @@ create_lookup_params(RSM, Direction, ArcID, CallerJID, OwnerJID) -> flip_page => false, ordering_direction => Direction, limit_passed => true, - caller_jid => CallerJID}. + caller_jid => CallerJID, + message_ids => undefined}. patch_fun_to_make_result_as_map(F) -> fun(HostType, Params) -> result_to_map(F(HostType, Params)) end. diff --git a/src/mongoose_data_forms.erl b/src/mongoose_data_forms.erl index 5c83190ca90..55a4edb8671 100644 --- a/src/mongoose_data_forms.erl +++ b/src/mongoose_data_forms.erl @@ -16,13 +16,14 @@ -type form() :: #{type => binary(), title => binary(), instructions => binary(), ns => binary(), fields => [field()], reported => [field()], items => [[field()]]}. -type field() :: #{var => binary(), type => binary(), label => binary(), - values => [binary()], options => [option()]}. + values => [binary()], options => [option()], validate => validate()}. -type option() :: binary() | {binary(), binary()}. +-type validate() :: #{method => atom(), datatype => binary()}. -type parsed_form() :: #{type => binary(), ns => binary(), kvs := kv_map()}. -type kv_map() :: #{binary() => [binary()]}. --export_type([form/0, field/0, option/0, kv_map/0]). +-export_type([form/0, field/0, option/0, validate/0, kv_map/0]). -ignore_xref([is_form/1]). % exported for consistency, might be used later @@ -128,10 +129,12 @@ form_type_field(NS) when is_binary(NS) -> -spec form_field(field()) -> exml:element(). form_field(M) when is_map(M) -> + Validate = form_field_validate(maps:get(validate, M, [])), Values = [form_field_value(Value) || Value <- maps:get(values, M, [])], Options = [form_field_option(Option) || Option <- maps:get(options, M, [])], - Attrs = [{atom_to_binary(K), V} || {K, V} <- maps:to_list(M), K =/= values, K =/= options], - #xmlel{name = <<"field">>, attrs = Attrs, children = Values ++ Options}. + Attrs = [{atom_to_binary(K), V} + || {K, V} <- maps:to_list(M), K =/= values, K =/= options, K =/= validate], + #xmlel{name = <<"field">>, attrs = Attrs, children = Values ++ Options ++ Validate}. -spec form_title(binary()) -> exml:element(). form_title(Title) -> @@ -160,3 +163,14 @@ form_field_option(Option) -> -spec form_field_value(binary()) -> exml:element(). form_field_value(Value) -> #xmlel{name = <<"value">>, children = [#xmlcdata{content = Value}]}. + +-spec form_field_validate(validate()) -> [exml:element()]. +form_field_validate(#{method := Method, datatype := Datatype}) -> + [#xmlel{name = <<"validate">>, + attrs = [{<<"xmlns">>, ?NS_DATA_VALIDATE}, {<<"datatype">>, Datatype}], + children = form_field_validation_method(Method)}]; +form_field_validate(_) -> []. + +-spec form_field_validation_method(atom()) -> [exml:element()]. +form_field_validation_method(open) -> + [#xmlel{name = <<"open">>}].